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 #
- 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.
- Build the signed text: the timestamp, a period, then the raw body.
- Compute HMAC-SHA256 of that text with your secret, as lowercase hex, and prefix it with
sha256=. - Compare with the signature header in constant time. Accept the message only when they match.
- Answer with any 2xx status once you have stored the message. Anything else counts as a failure and Dispatch retries.
- Use
X-Dispatch-Deliveryto 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.