The quiet details of a good webhook: HMAC, timestamps, replay windows
A walk through the verification contract we send to your endpoint, why the timestamp header matters as much as the signature, and what we learned shipping it.
Webhook contracts are one of those pieces of engineering where the details do not announce themselves. The thing works or it does not. When it does not, you are debugging in production at 2am because a retry storm doubled up a refund. The details matter.
This is our delivery contract, what each piece does, and how a receiver should handle it. Most of it reads like Stripe's contract with fewer commas, because Stripe's shape is correct and there is no reason to be clever.
What gets signed, and how
Every delivery carries two headers:
X-FinalApproval-Signature-256: sha256=<hex>
X-FinalApproval-Timestamp: <unix seconds>
The signature is an HMAC-SHA256 computed over timestamp + "." + rawBody, using the channel's webhook secret as the key. Output is hex, prefixed with sha256= to leave room for algorithm upgrades without breaking the parser.
The body is the exact bytes we sent. Whatever JSON.stringify produced on our side is the input to the HMAC, and whatever your framework hands you as the raw buffer is the input on yours. This is the first place people trip: a body parser that re-serializes will quietly change whitespace, and the hash will not match. Read the body before any JSON middleware touches it.
Why the timestamp is in the header, not just the payload
Two reasons. The receiver can reject stale requests before parsing anything, which shortens the replay window and keeps the parse path simple. And the timestamp is part of the signature input, so a request cannot be replayed with a modified timestamp without invalidating the signature.
If the timestamp were only inside the JSON body, you could not validate it before parsing — and parsing untrusted JSON from an unverified source is a category of risk worth avoiding when the alternative is two header reads.
The five-minute replay window
The receiver should reject any request whose timestamp is more than 300 seconds from the current time, in either direction. Shorter and you reject legitimate deliveries when the receiver's clock drifts. Longer and you widen the replay surface.
We do not enforce the window — we sign what we send, we do not know your clock. The receiver enforces. A server with NTP configured will be well inside 300 seconds. A server without NTP should not be verifying webhooks.
Constant-time comparison, always
Compare the signature using crypto.timingSafeEqual or its equivalent. Do not use ===. The early-exit behavior of a regular string comparison leaks how many leading characters matched, which is enough, given enough requests, to reconstruct a valid signature one byte at a time. It is a one-line fix. Just do it.
A Node verifier, in full
import crypto from "node:crypto";
import type { Request, Response } from "express";
const SECRET = process.env.FA_WEBHOOK_SECRET;
const MAX_SKEW_SECONDS = 300;
export function verifyFinalApprovalWebhook(req: Request, res: Response, buf: Buffer) {
const sigHeader = req.header("X-FinalApproval-Signature-256") ?? "";
const tsHeader = req.header("X-FinalApproval-Timestamp") ?? "";
const ts = Number(tsHeader);
if (!Number.isFinite(ts)) throw new Error("bad timestamp");
const skew = Math.abs(Math.floor(Date.now() / 1000) - ts);
if (skew > MAX_SKEW_SECONDS) throw new Error("stale webhook");
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${tsHeader}.${buf.toString("utf8")}`)
.digest("hex");
const provided = sigHeader.replace(/^sha256=/, "");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(provided, "hex");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error("bad signature");
}
}
Mount it against the raw body, not the parsed body. In Express: app.post("/hook", express.raw({ type: "application/json" }), handler). If you see a signature mismatch and the secret is correct, your body parser is the problem.
Why symmetric, not asymmetric
HMAC is shared-secret. You have the secret, we have the secret. A leak on either side compromises the channel. Asymmetric signing (Ed25519, RSA) would let us sign with a private key and you verify with a public key.
We chose HMAC because the operational story is simpler. Rotating a shared secret is a single API call and a restart of your verifier. Rotating a signing key means publishing key sets, handling overlap periods, and teaching every integrator what a JWKS endpoint is. If a secret leaks, rotate it. The blast radius is the channel, not the platform.
Retry strategy
Up to eight attempts with jittered exponential backoff over a ~24-hour horizon: 30s, 2m, 10m, 30m, 2h, 6h, 12h, then give up. Each attempt has a 5-second HTTP timeout.
Jitter is not decorative. When a receiver comes back online after an outage, unjittered retries from many channels arrive in a thundering herd and knock it back down. Jitter spreads the reconnect load across the retry window.
Delivery is at-least-once. If we send and your server 200s but the TCP connection gets torn down before we read the response, we will retry. Build the receiver to handle duplicates.
Idempotency on the receiver
Every approval has a stable id. Use it as the dedupe key on your side. The shape is:
const seen = await db.query(
"SELECT 1 FROM webhook_events WHERE approval_id = $1",
[payload.approval.id],
);
if (seen.rowCount) return res.status(200).end();
await db.query(
"INSERT INTO webhook_events (approval_id, received_at) VALUES ($1, now())",
[payload.approval.id],
);
await doTheAction(payload);
res.status(200).end();
INSERT-before-action matters. If the action fails and you want to retry internally, that is your business — do not rely on our retries. Our retries are for network failures, not for your action failing.
Return 2xx when you have durably accepted the event. Return 5xx when you want us to retry. Return 4xx only when the request is genuinely malformed; we stop retrying after a 4xx.
Delivery logging
Every attempt is logged with status code, response time, error string, and attempt number, visible on the channel detail page. The failure is often not on our end but the log tells you which end. More on the webhook delivery feature page.
Failure modes, and how to behave
- Receiver briefly down. Retries cover it. No code needed.
- Receiver down beyond the 24-hour horizon. We stop retrying. You need a reconciliation path — on boot, query our API for approvals resolved since your last-seen timestamp.
- Signature mismatch. Almost always a body parser problem or a stale secret after rotation. Fix on your side; we will keep retrying until the window closes.
- Clock skew. Run NTP. On container platforms, confirm the host clock is synced — some providers do not, by default, and it shows up here first.
- Duplicate delivery. Your idempotency check catches it. Return 200.
The contract is small on purpose. Two headers, one hash, one timestamp window, a retry schedule. Any one of them wrong turns the whole thing into an intermittent bug at the worst hour.