Free trial
Colour

Verifying webhook signatures

Check the signature and timestamp on each webhook delivery so you act only on messages that came from your Dispatch server.

For
Administrator
Hosting
Any hosting
Devices
All devices
Business
All businesses

Checked against Pop Street Dispatch 0.1.0

Every delivery carries a signature made with your endpoint's secret. You will be able to confirm that a message came from your server and arrived fresh.

Before you start #

The endpoint's secret (it starts with whsec_) and the raw request body, exactly as received. Compute the signature from the raw bytes, before any JSON parsing.

The delivery #

A delivery is a POST with a JSON body:

{"event":"invoice.paid","created_at":"2026-10-28T14:00:00Z","data":{...}}

and these headers:

  • X-Dispatch-Event: the event name.
  • X-Dispatch-Delivery: the delivery number.
  • X-Dispatch-Timestamp: seconds since 1 January 1970 (UTC) when it was sent.
  • X-Dispatch-Signature: sha256= followed by the lowercase hex HMAC.
  • User-Agent: PopStreetDispatch-Webhooks/1.

Steps #

  1. Read the timestamp header. If it differs from your own clock by more than 300 seconds, reject the message. This stops replays of old deliveries.
  2. Build the signed text: the timestamp, a period, then the raw body.
  3. Compute HMAC-SHA256 of that text with your secret, as lowercase hex, and prefix it with sha256=.
  4. Compare with the signature header in constant time. Accept the message only when they match.
  5. Answer with any 2xx status once you have stored the message. Anything else counts as a failure and Dispatch retries.
  6. Use X-Dispatch-Delivery to ignore a delivery you already handled, because a retry can arrive after a slow success.

Example in Node #

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

export function verify(secret, timestamp, rawBody, signature, nowSeconds) {
  if (Math.abs(nowSeconds - Number(timestamp)) > 300) return false;
  const expected = 'sha256=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signature.trim());
  return a.length === b.length && timingSafeEqual(a, b);
}
Technical details
  • The body is at most 256 KiB.
  • The secret is whsec_ plus 40 lowercase hexadecimal characters.
  • A test delivery uses the same signing as a live one.

If something goes wrong #

  • Every signature mismatches: check that you hash the raw body and the right endpoint's secret. After "Rotate secret", only the new secret works.
  • Every message looks stale: set your server clock from a time service.