Skip to content
Listener.comDevelopers

Verify signatures

Check the Listener-Signature header so you only act on requests that really came from Listener.

2 min read

Anyone who learns your endpoint URL can send it requests. Every genuine delivery is signed with your endpoint’s secret, so verify the signature before you trust the body.

The signature header#

HTTP
Listener-Signature: t=1792056600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t is the Unix time (seconds) when Listener signed the request.
  • v1 is the hex-encoded HMAC-SHA256 of the string "{t}.{raw_body}", keyed with your endpoint’s signing secret.
  • During a secret rotation the header carries a second v1 value, signed with the previous secret.

How to verify#

  1. Read the raw request body exactly as received. Parsing and re-serialising JSON changes bytes and breaks the signature.
  2. Split the header on , and collect t and every v1 value.
  3. Build the signed payload: the value of t, a literal ., then the raw body.
  4. Compute HMAC-SHA256 of that payload with your secret (the whole whsec_… string, UTF-8 encoded) and hex-encode it.
  5. Compare it with each v1 using a constant-time comparison. Accept if any matches.
  6. Reject the request if t is more than five minutes from your clock. This stops an attacker replaying an old delivery.

Code#

The SDKs do all of this in one call (listener.webhooks.constructEvent in TypeScript, webhooks.construct_event in Python). Without an SDK:

// Express. Mount with the raw body parser so you can verify the exact bytes:
// app.post('/webhooks/listener', express.raw({ type: 'application/json' }), handler);
import crypto from 'node:crypto';

const TOLERANCE_SECONDS = 300;

export function verifyListenerSignature(rawBody, header, secret) {
  if (!header) return false;
  let timestamp = null;
  const signatures = [];
  for (const part of header.split(',')) {
    const [key, value] = part.split('=', 2);
    if (key === 't') timestamp = Number(value);
    if (key === 'v1' && value) signatures.push(value);
  }
  if (!Number.isFinite(timestamp) || signatures.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.`)
    .update(rawBody) // a Buffer from express.raw()
    .digest();
  return signatures.some((sig) => {
    const given = Buffer.from(sig, 'hex');
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}

export function handler(req, res) {
  const ok = verifyListenerSignature(req.body, req.get('Listener-Signature'), process.env.LISTENER_WEBHOOK_SECRET);
  if (!ok) return res.status(400).send('Invalid signature');
  const event = JSON.parse(req.body.toString('utf8'));
  queue.add(event); // do the work later
  res.sendStatus(200);
}

During secret rotation#

After you call POST /v1/webhook-endpoints/{id}/roll-secret, deliveries for the next 24 hours carry two signatures:

HTTP
Listener-Signature: t=1792056600,v1=<signed with the new secret>,v1=<signed with the previous secret>

The code above accepts a request when any v1 matches, so you can deploy the new secret whenever it suits you within those 24 hours. If you verify against several secrets yourself, check each one against each v1.

Common mistakes#

SymptomCause
Every signature failsThe body was parsed before verifying. Use the raw bytes.
Fails only in productionA proxy or framework re-encodes the body, or the wrong secret is deployed for that endpoint. Each endpoint has its own secret.
Fails after a whileYour server clock drifted beyond five minutes. Sync it with NTP.
Fails right after rotatingYou replaced the secret in code but kept comparing only one signature. Accept any v1.