
Webhooks That Don't Lie: Idempotency, Signatures, Replay, and a Playbook
An opinionated production guide to receiving webhooks — signature verification, idempotency keys, store-then-process pipelines, dead-letter queues, replay UIs, provider quirks, and a local testing loop your team will actually use.
Verify the signature, reject clock skew, store the raw payload with an idempotency key, then enqueue processing. The 200 is a receipt, not a completion. Make processing idempotent with unique constraints, add a DLQ with a one-click replay UI, and test locally with a stable tunnel + recorded fixtures. Do these five things and webhook incidents drop to near zero.
- Signature verification happens BEFORE parsing the body — always timing-safe.
- Store-then-process: the HTTP 200 acknowledges receipt, not business completion.
- Idempotency keys live in a unique index, not in application code.
- Every failure lands in a DLQ with a replay button — half of incidents are 'replay after fix'.
- Providers retry aggressively. Assume every event will arrive at least twice.
The four rules
- Verify the signature before touching the payload. Use timing-safe compare. Reject with 401 on failure.
- Reject clock skew — parse the timestamp header, allow ±5 minutes. Stops replay attacks cheaply.
- Store, then process — write the raw payload to a table with an idempotency key, then enqueue. The 200 is a receipt, not a completion.
- Make processing idempotent — same event delivered twice must produce the same result. Use unique constraints, not
ON CONFLICT DO NOTHINGwithout care.
Signature verification: get it right the first time
Every mature provider (Stripe, GitHub, Shopify, Slack) signs webhooks with HMAC-SHA256 over the concatenation of timestamp + body. The exact separator differs; read the docs, then wrap it in one helper.
import { createHmac, timingSafeEqual } from "crypto";
export function verifySignature(opts: {
secret: string;
timestamp: string;
body: string; // RAW body, not JSON.stringified
signature: string;
toleranceSec?: number;
}) {
const tol = opts.toleranceSec ?? 300;
const nowSec = Math.floor(Date.now() / 1000);
const ts = Number(opts.timestamp);
if (!Number.isFinite(ts) || Math.abs(nowSec - ts) > tol) {
return { ok: false, reason: "stale" as const };
}
const expected = createHmac("sha256", opts.secret)
.update(opts.timestamp + "." + opts.body)
.digest("hex");
const a = Buffer.from(opts.signature);
const b = Buffer.from(expected);
const ok = a.length === b.length && timingSafeEqual(a, b);
return ok ? { ok } : { ok: false, reason: "bad_signature" as const };
}Store-then-process: the single most important pattern
Providers retry aggressively — Stripe up to 3 days, GitHub 24 hours, Shopify 48 hours. If your handler does the business work inline and one downstream call fails, you'll either return 500 (and get retried) or 200 (and silently lose the event). Neither is acceptable.
The fix is boring and universal:
- Verify signature.
- Insert raw payload into
webhook_eventswith an idempotency key on the provider event ID. - Enqueue a background job with the row ID.
- Return 200.
create table webhook_events (
id text primary key, -- provider event id, e.g. "evt_1P..."
provider text not null,
event_type text not null,
payload jsonb not null,
signature text not null,
received_at timestamptz default now(),
status text default 'received', -- received | processing | done | failed
attempts int default 0,
last_error text,
processed_at timestamptz
);
create index on webhook_events (status, received_at);
create index on webhook_events (provider, event_type);export async function handleWebhook(req: Request) {
const raw = await req.text();
const sig = req.headers.get("x-signature") ?? "";
const ts = req.headers.get("x-timestamp") ?? "0";
const verdict = verifySignature({ secret: SECRET, timestamp: ts, body: raw, signature: sig });
if (!verdict.ok) return new Response(verdict.reason, { status: 401 });
const event = JSON.parse(raw) as { id: string; type: string };
const inserted = await db.query(
`insert into webhook_events (id, provider, event_type, payload, signature)
values ($1, $2, $3, $4::jsonb, $5)
on conflict (id) do nothing
returning id`,
[event.id, "stripe", event.type, raw, sig],
);
if (inserted.rowCount > 0) {
await queue.publish("webhook.received", { id: event.id });
}
return new Response("ok"); // 200 whether new or duplicate
}Idempotency the database enforces
Application-level idempotency ("check then insert") races itself under retry storms. Push the invariant into a unique constraint and let the database refuse duplicates atomically.
- Idempotency key = provider event ID for inbound webhooks.
- For internal effects (charges, emails), derive a key like
{event_id}:{action}and unique-index the effects table on it. - Wrap the whole processing step in a transaction: read event → apply effects → mark
done. Any failure rolls back and the event is safely retried.
create table webhook_effects (
key text primary key, -- e.g. "evt_1P...:charge_customer"
event_id text references webhook_events(id),
action text not null,
result jsonb,
created_at timestamptz default now()
);DLQ + replay UI is not optional
Every event is already a row. Add a small UI that lists failed events with filters (provider, type, date, error class), a diff of the payload, and a one-click Replay button that re-enqueues the row after resetting status='received' and attempts=0.
In production, roughly half of all webhook incidents follow the same script: an upstream bug ships → events fail → the bug is fixed → ops replays the DLQ → the world is consistent again. Without a replay UI, that last step becomes an all-hands SQL exercise at 2am.
Retry, backoff, and the poison-pill event
A well-behaved consumer retries with exponential backoff + jitter, capped by a max attempts (I default to 8). Classify errors first:
| Class | Example | Action |
|---|---|---|
| Transient | 5xx from downstream, timeout | Retry with backoff |
| Rate-limited | 429 | Honor Retry-After, otherwise long backoff |
| Permanent | 4xx, schema violation | Send to DLQ; do not retry |
| Poison | Handler crashes on parse | Quarantine event, alert, keep queue moving |
function nextDelayMs(attempt: number) {
const base = 500;
const cap = 30_000;
const upper = Math.min(cap, base * 2 ** attempt);
return Math.floor(Math.random() * upper); // full jitter
}Provider quirks worth knowing
- Stripe — signs
timestamp.body; headerStripe-Signaturecontains multiple key=value pairs. - GitHub — signs the raw body only; header is
X-Hub-Signature-256prefixed withsha256=. - Shopify — HMAC in
X-Shopify-Hmac-Sha256, base64-encoded (not hex). - Slack — signs
v0:timestamp:bodyand the header isX-Slack-Signatureprefixed withv0=. - Twilio — signs the full URL + sorted params; if you're behind a proxy, reconstruct the URL from headers or verification fails silently.
X-Forwarded-* headers before hashing.Testing webhooks locally without pain
The traditional loop — ngrok, click through the provider dashboard, wait — is slow and flaky. Two better options:
- Cloudflare Tunnel for a stable named URL you register once with the provider.
- Hookdeck or Convoy in front of your provider: they queue events, replay them on demand, and fan out to any environment (localhost, staging, prod) with history and retries built in.
Then commit a fixtures/ folder of recorded real events (redacted of PII) so you can run the handler under Vitest without any network at all.
import fixture from "../fixtures/stripe/checkout.session.completed.json";
import { handleWebhook } from "@/webhooks/stripe";
import { signFixture } from "@/test/webhook-signer";
test("checkout.session.completed inserts, enqueues, returns 200", async () => {
const { body, headers } = signFixture(fixture);
const res = await handleWebhook(new Request("http://x", { method: "POST", body, headers }));
expect(res.status).toBe(200);
const row = await db.oneOrNone("select * from webhook_events where id=$1", [fixture.id]);
expect(row?.status).toBe("received");
});Observability: know before ops asks
- Metrics per provider + event_type: received, duplicate, verified, failed, replayed.
- Latency histogram from received_at → done_at. Alert on p95 > 30s.
- DLQ size — alert on any non-zero DLQ that hasn't drained in 15 minutes.
- Signature failures — a small baseline is normal (scanners); a spike means a secret rotation happened without updating you.
Frequently asked
Should webhooks be public or authenticated?
Public URL, but every request is authenticated by signature. Don't put webhooks behind session auth — providers don't have your cookies. IP allowlisting is a defense-in-depth bonus, not a substitute for signature verification.
Do I really need a queue, or can I process inline?
If your handler always finishes in under a few hundred milliseconds and downstream systems are rock-solid, inline is fine. In practice, the queue pays for itself the first time an upstream service goes down and you'd otherwise return 500 for an hour.
How do I handle out-of-order events?
Providers can deliver 'updated' before 'created' after a retry. Version your entities (updated_at from the payload) and only apply writes whose payload is newer than the current row. Discard the rest as no-ops, don't error.
Where does replay live in an incident runbook?
First: stop accepting bad events (feature flag or 503 on the offending type). Second: fix the bug and deploy. Third: replay the DLQ in batches, watching metrics. Fourth: post-mortem with a copy of the diff between failed and replayed rows.
Building something similar?
I help teams ship production-grade AI agents, n8n workflows, and data platforms. Let's talk about what you're building.
Work with me
Islam Gamal
AI, Data & Automation Engineer. I design and ship production AI agents, n8n workflows, and cloud data platforms — with a focus on reliability, cost, and measurable business impact. Founder of Tashghil and Tek bil Arabi.
More from Islam Gamal
The n8n Error Handling Playbook: Retries, DLQs, Circuit Breakers, and Replay
The full production playbook for n8n reliability: how to classify failures, design retry policies that don't melt upstreams, build a Postgres DLQ with safe replay, run circuit breakers per integration, and wire the observability that catches incidents before your customers do.
BigQuery Cost Control That Scales: 12 Patterns from Real Warehouses
Twelve battle-tested patterns to cut BigQuery spend 30–70% without touching data quality — partitioning, clustering, materialized views, reservations, and the FinOps loop that keeps savings from decaying.
Designing REST APIs LLMs Can Actually Use: A Field Guide for Tool-Using Agents
The API design choices that make or break tool-using LLM agents — naming, error shapes, idempotency, discoverability, JSON schema hygiene, pagination, and the eight anti-patterns that confuse every model.
Browse every article by Islam Gamal on the author page.