Skip to main content

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.

Migrating from the legacy webhook signature scheme

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:

HeaderDescriptionExample
webhook-idIdentifier for this delivery, stable across retry attempts of the same delivery123e4567-e89b-12d3-a456-426614174000
webhook-timestampUnix timestamp (seconds) when the request was signed1704123456
webhook-signatureHMAC-SHA256 signature over the signed content, used to verify delivery authenticity. One space-delimited signature per active secretv1,efSvda2kG6w0DOGsk/EqMUPYiGeqMHfaRoB9D0RI2GI=
content-typeAlways the CloudEvents structured-mode content typeapplication/cloudevents+json
user-agentIdentifies the delivery senderHelix-Event-Delivery/1.0
Migrating from the legacy webhook headers

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:

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);
});
}
Capture the raw body

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().

The secret is encoded

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:

  1. Rotate the secret via the API.
  2. 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.
  3. 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.