Verifying signatures
Your endpoint is a public URL, so anyone can post to it. Every delivery we make is signed, and checking that signature is what separates our events from someone else's.
The headers
Webhook-Id: 4f9b2c31-...
Webhook-Timestamp: 1755610000
Webhook-Signature: t=1755610000,v1=3a7f...
Content-Type: application/json
User-Agent: Epostix-Webhooks/1.0
Webhook-Id identifies the delivery attempt. Webhook-Timestamp is Unix seconds.
Webhook-Signature carries the timestamp again and one or more signature versions.
What is signed
An HMAC-SHA256 over the timestamp, a full stop, and the raw request body, keyed with your endpoint secret, hex encoded.
signature = hex(hmac_sha256(secret, "{timestamp}.{raw body}"))
The raw body matters. Parse the JSON and re-serialise it before signing and the bytes change, so the signature will not match. Capture the body as received.
Checking it
const raw = await readRawBody(request);
const header = request.headers["webhook-signature"];
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = hmacSha256Hex(secret, `${parts.t}.${raw}`);
if (!timingSafeEqual(expected, parts.v1)) reject();
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) reject();
accept(JSON.parse(raw));
Two rules that are easy to skip and both matter:
Compare in constant time. A plain string comparison leaks how much of the signature was correct, one character at a time. Use your language's constant-time comparison.
Check the timestamp. Without it, a signature captured once stays valid forever, so an intercepted delivery can be replayed at any point. Five minutes of tolerance is a reasonable default.
Use an existing library
Our format is Standard Webhooks compatible, so the published libraries for that specification verify our deliveries without modification. Reach for one rather than writing the checks above, and you get the constant-time comparison and the timestamp tolerance for free.
Rotation
Rotating an endpoint's secret returns a new one and keeps the previous secret valid for 24 hours. That window exists so you can deploy the new secret without dropping deliveries.
Accept a delivery if it verifies against either secret during the changeover:
- Rotate, and store the new secret alongside the old one.
- Deploy code that tries both.
- Once the 24 hours have passed, drop the old secret.
Webhook-Signature can carry more than one v1= value, so checking each against your
current secrets is enough.
Reject before you act
Verify first, then parse, then do the work. An endpoint that updates state before checking the signature is an endpoint anyone can drive.
Return a 2xx once you have accepted the event, and do the slow part afterwards. We retry on anything else, at 0, 5 minutes, 30 minutes, 2 hours and 24 hours, and an endpoint that keeps failing for 7 days is disabled automatically.
Duplicates are normal
A retry can arrive after your handler already succeeded but its response was lost. Treat
Webhook-Id as the deduplication key and make your handler safe to run twice on the same
event.