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=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdtis the Unix time (seconds) when Listener signed the request.v1is 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
v1value, signed with the previous secret.
How to verify#
- Read the raw request body exactly as received. Parsing and re-serialising JSON changes bytes and breaks the signature.
- Split the header on
,and collecttand everyv1value. - Build the signed payload: the value of
t, a literal., then the raw body. - Compute HMAC-SHA256 of that payload with your secret (the whole
whsec_…string, UTF-8 encoded) and hex-encode it. - Compare it with each
v1using a constant-time comparison. Accept if any matches. - Reject the request if
tis 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#
| Symptom | Cause |
|---|---|
| Every signature fails | The body was parsed before verifying. Use the raw bytes. |
| Fails only in production | A 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 while | Your server clock drifted beyond five minutes. Sync it with NTP. |
| Fails right after rotating | You replaced the secret in code but kept comparing only one signature. Accept any v1. |