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 scheme changed completely. The old scheme was
HMAC-SHA256(secret, "${timestamp}.${payload}"), hex-encoded, prefixed
v1= (equals sign), with the timestamp read from a header in
milliseconds. The new scheme is HMAC-SHA256(secret, "${msgId}.${timestamp}.${body}"), base64-encoded, prefixed v1,
(comma), with the timestamp in seconds. These are not compatible.
Rewrite your verification code — do not attempt to patch the old
implementation to accept the new headers.
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 |
The legacy webhook system sent capitalized X-Webhook-* headers, including a distinct X-Webhook-Event-ID. Event delivery renames these to the lowercase Standard Webhooks names above and drops the separate event-id header — the envelope's id field (see the payload examples) is the delivery identifier. If your integration greps for X-Webhook-Signature, it will find nothing: signing was not removed, only renamed to webhook-signature.
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.