Signature Verification
Event Delivery signs every request using the Standard Webhooks specification. Adopting a public spec, rather than a Helix-specific scheme, means you verify signatures with an existing, audited library instead of hand-rolling HMAC comparison code.
The signature is base64-encoded (not hex) and prefixed v1, (a comma, not
=), and webhook-timestamp is in seconds, not milliseconds. Code
written for another HMAC scheme will not verify these signatures.
Headers
Every event delivery request carries the following headers, defined by the Standard Webhooks specification:
| Header | Description | Example |
|---|---|---|
webhook-id | Identifier for this delivery, stable across retry attempts of the same delivery | 123e4567-e89b-12d3-a456-426614174000 |
webhook-timestamp | Unix timestamp (seconds) when the request was signed | 1704123456 |
webhook-signature | HMAC-SHA256 signature over the signed content, used to verify delivery authenticity. One space-delimited signature per active secret | v1,efSvda2kG6w0DOGsk/EqMUPYiGeqMHfaRoB9D0RI2GI= |
content-type | Always the CloudEvents structured-mode content type | application/cloudevents+json |
user-agent | Identifies the delivery sender | Helix-Event-Delivery/1.0 |
How the signature is computed
The signed content is {webhook-id}.{webhook-timestamp}.{raw body}, HMAC-SHA256'd
with the destination's decoded secret, base64-encoded, and prefixed with the
scheme version:
v1,base64(HMAC-SHA256(secret, `${webhook-id}.${webhook-timestamp}.${rawBody}`))
The webhook-signature header can carry more than one space-delimited
signature during a secret rotation — verify against
any one of them.
Use an off-the-shelf library
Because the scheme follows the Standard Webhooks spec exactly, you do not need to implement HMAC comparison yourself. Reference libraries exist for most languages:
- Node / TypeScript:
standardwebhooks - Python:
standardwebhooks - Go:
go-standard-webhooks - Ruby:
standardwebhooks
const express = require('express');
const { Webhook } = require('standardwebhooks');
const secret = 'whsec_...'; // the whsec_… value from your destination
const wh = new Webhook(secret);
const app = express();
app.post(
'/events',
express.raw({ type: 'application/cloudevents+json' }),
(req, res) => {
let event;
try {
event = wh.verify(req.body, req.headers);
} catch (err) {
return res.status(401).send('Invalid signature');
}
res.status(200).send('OK');
// process `event` asynchronously below, using event.id as your
// idempotency key
}
);
The library handles timestamp tolerance, multi-secret verification during rotation, and constant-time comparison for you.
Verifying by hand
If your language has no Standard Webhooks library, verify manually against the raw request body bytes — parsing and re-serializing JSON first will change key ordering and break the signature:
const crypto = require('crypto');
function verifySignature(rawBody, headers, secret, toleranceSeconds = 300) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signature = headers['webhook-signature'];
if (
typeof id !== 'string' ||
typeof timestamp !== 'string' ||
typeof signature !== 'string'
) {
return false;
}
// webhook-timestamp is in SECONDS, not milliseconds.
const ageSeconds = Math.floor(Date.now() / 1000) - Number(timestamp);
if (Math.abs(ageSeconds) > toleranceSeconds) return false;
// The secret is `whsec_` + base64. Strip the prefix and decode to get the
// raw HMAC key bytes.
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const signedContent = `${id}.${timestamp}.${rawBody}`;
const expected =
'v1,' +
crypto.createHmac('sha256', key).update(signedContent).digest('base64');
const expectedBuf = Buffer.from(expected, 'utf8');
return signature
.split(' ')
.filter(Boolean)
.some((candidate) => {
const candidateBuf = Buffer.from(candidate, 'utf8');
if (candidateBuf.length !== expectedBuf.length) return false;
return crypto.timingSafeEqual(candidateBuf, expectedBuf);
});
}
Most frameworks parse JSON for you, which can reorder keys. Capture the raw
body before parsing — e.g. in Express, app.use(express.raw({ type: 'application/cloudevents+json' })) on the route, not express.json().
A destination's signing secret is whsec_-prefixed and base64-encoded
internally. A Standard Webhooks library's Webhook constructor decodes this
for you. If you verify by hand, decode the portion after whsec_ before
using it as the HMAC key.
Rotation with overlap
POST /events/destinations/{ref}/secrets/rotate
mints a new secret and sets the current (never-expiring) secret to expire 24
hours out, rather than invalidating it immediately. A secret already counting
down from an earlier rotation keeps its original expiry. During that overlap
window,
every request is signed with both the old and new secret — the
webhook-signature header carries one space-delimited signature per active
secret. This means:
- Rotate the secret via the API.
- Update your verifier to accept the new secret — at your own pace, within the 24-hour window. Your existing verifier keeps working the whole time, since it's still checking against a still-valid secret.
- Once you've deployed the new secret, the old one expires on its own — no coordinated cutover required.
GET /events/destinations/{ref}/secrets
returns every secret still valid for verification, including ones inside
their overlap window. A destination holds at most 10 active secrets; a
rotation beyond that is a 409 until the oldest overlapping secret expires.
If a secret leaks
Rotation's overlap is the wrong tool for a compromise — the leaked secret
would keep signing for 24 hours. Rotate, deploy the new secret to your
verifier, then retire the leaked one at once with
POST /events/destinations/{ref}/secrets/{secretId}/expire.
The only thing it refuses is expiring a destination's last active secret,
which is why rotation comes first.
Next steps
- Reliability — retries, replay, and what to do when your endpoint has been down.
- API Reference — the full
/events/*surface.