Skip to content

Verify the signature of a delivery

Recompute HMAC-SHA256 over the raw body, compare it with X-Signature in constant time, deduplicate by X-Delivery-Id, and rotate the secret.

4 minute readWebhooksUpdated: 10/25/2026

On this page

Before you start

For
Anyone who programs the receiver for webhooks.
Prerequisite
A created endpoint and its signing secret (whsec_…), as shown on creation or after Rotate secret.
Where
Your own code. You renew the secret under Settings › Organization › Webhooks.

By the end, your receiver accepts only deliveries that really come from the platform, processes each one exactly once, and survives a change of the secret.

Steps

  1. Read the body as raw bytes before anything interprets it as JSON. The signature covers exactly the bytes that were sent. If you parse the body first and serialize it again, you may change the order or spacing and get a different signature.
  2. Compute HMAC-SHA256 over these bytes with the secret as the key, and encode the result as base64url, the way Node.js produces it with digest('base64url').
  3. Compare with X-Signature in constant time and only for equal length. An ordinary character comparison stops at the first difference and reveals through its running time how many characters match.
  4. Branch by X-Event-Type. The header gives the event type without you having to read the body.
  5. Deduplicate by X-Delivery-Id. Remember every processed ID. A retry carries the same ID. If it is known, respond with 2xx and do nothing else.
  6. Only now process, and respond with 2xx quickly. The timeout is ten seconds. Do long-running work after you respond.

This is what the check looks like in Node.js. It is the example that the portal also shows under Verify deliveries are genuine:

import { createHmac, timingSafeEqual } from 'node:crypto';

// Sign over the RAW body, not over re-serialized JSON:
// any re-serialization can change the order or the spacing.
export function isFromSignage(rawBody: string, header: string, secret: string): boolean {
  const expected = createHmac('sha256', secret).update(rawBody).digest('base64url');
  const a = Buffer.from(expected);
  const b = Buffer.from(header);
  return a.length === b.length && timingSafeEqual(a, b);
}

Call it from your receiver like this: isFromSignage(rawBody, req.headers['x-signature'], process.env.WEBHOOK_SECRET). Depending on the server, header names arrive in lowercase. The platform sends X-Signature.

Options

Renew the secret

The secret has the form whsec_ plus at least 32 characters from A–Z, a–z, 0–9, _, and -. It is shown only on creation and after a rotation and cannot be retrieved. If it is lost or you want to change it:

  1. Set up your receiver so that it can pick up the new secret right after it is shown, for example through a configuration that reloads without a restart.
  2. In the portal, click Rotate secret on the endpoint (locked without a package that includes webhooks), or call POST /v1/webhooks/{id}/rotate-secret. Through the API, this remains possible even without a package.
  3. Copy the new secret from the Signing secret — visible only now box (Copy secret) or from the response {"secret": "whsec_…"}.

From the next send onward, only the new secret is used for signing, including retries of older deliveries. There is no transition phase in which both apply. Until your receiver knows the new secret, it rejects genuine deliveries. The platform retries them only if you respond with 5xx instead of 4xx during that time. The address, events, and delivery history of the endpoint are kept during the rotation.

Check the result

  • A genuine delivery passes the check, and in the Delivery log it appears as Delivered with your response code.
  • A request with a modified body or without a valid X-Signature is rejected.
  • A redelivered ID (Retry in the delivery log) does not trigger a second processing on your side.

If something goes wrong

Symptom Meaning Solution
Signature never matches The body was changed before the check (serialized again, character set converted, whitespace removed) Check the raw bytes before a JSON parser or a framework touches the body
Signature no longer matches since a certain point in time The secret was rotated, and your receiver still checks with the old one Switch to the new secret. For deliveries from the gap, use Retry in the delivery log
Signature matches, comparison fails anyway Length check or encoding is wrong (hex instead of base64url, padding characters appended) Use exactly the form from the example
The same delivery is processed twice Deduplication is missing. A retry or a manual redelivery carried the same X-Delivery-Id Store IDs and acknowledge known IDs with 2xx