Webhooks Developer Guide
What is a webhook?
Verify the raw body, ACK in <1s, enqueue the slow work, dedupe on the event id.
The receiver pattern: verify HMAC-SHA256 over `id.timestamp.payload` against the RAW body with timingSafeEqual; reject stale timestamps (±5 min) to stop replays; INSERT the event id under a unique constraint to dedupe at-least-once delivery; return 2xx, then queue the work. Svix (and therefore Clerk) implement Standard Webhooks; Stripe's constructEvent is a close variant; GitHub signs the body as X-Hub-Signature-256. For codeAmani: Daraja callbacks aren't signed — verify by source IP + dedupe on CheckoutRequestID, ACK ResultCode 0 fast, and reconcile out of band.
Six things to get right
The rules that separate a safe receiver from a public mutation endpoint. Tap a card for the details.
Delivery simulator — does the receiver ACK, reject, or dedupe?
Send four kinds of delivery at a receiver and watch it run the three gates — verify the signature, check the timestamp, dedupe the event id — then decide its HTTP response. This is the whole webhook security model in one widget.
- ✓1 · Verify HMAC-SHA256 signaturematches signature over raw body
- ✓2 · Timestamp within ±5 min tolerancefresh, not a replay
- ✓3 · Event id not seen before (dedupe)first delivery — claim id, enqueue work
The receiver runs three gates in order — signature → freshness → dedupe. Only a bad signature or stale timestamp earns a 400; a duplicate is harmless, so it still returns 200 so the provider stops retrying.
██╗ ██╗███████╗██████╗ ██╗ ██╗ ██████╗ ██████╗ ██╗ ██╗███████╗
██║ ██║██╔════╝██╔══██╗██║ ██║██╔═══██╗██╔═══██╗██║ ██╔╝██╔════╝
██║ █╗ ██║█████╗ ██████╔╝███████║██║ ██║██║ ██║█████╔╝ ███████╗
██║███╗██║██╔══╝ ██╔══██╗██╔══██║██║ ██║██║ ██║██╔═██╗ ╚════██║
╚███╔███╔╝███████╗██████╔╝██║ ██║╚██████╔╝╚██████╔╝██║ ██╗███████║
╚══╝╚══╝ ╚══════╝╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝Webhooks Developer Guide
Focus: receive webhooks safely — verify the signature, ACK in under a second, then do the slow work on a queue. The same three moves work for Stripe, Clerk, Sentry, GitHub and M-Pesa/Daraja.
Overview
A webhook is a reverse API call: instead of you polling a provider for "did anything happen yet?", the provider POSTs a small JSON event to a URL you own the moment something happens — a payment succeeded, a user signed up, an error spiked. It is the push half of an event-driven system, and it is the cheapest way to react in near-real-time.
That convenience comes with three hard truths every receiver must respect:
- The internet is hostile. Your endpoint is public, so anyone can POST to it. You must cryptographically verify every request actually came from the provider — usually an HMAC-SHA256 signature over the raw body.
- Delivery is at-least-once, not exactly-once. Providers retry on timeout or non-2xx. The same event can arrive twice. You must be idempotent — dedupe on the event/delivery id.
- Slow receivers get retried (or disabled). Providers expect a fast 2xx ACK (Stripe, Clerk and most others want it within seconds). Do the verification + ACK fast, then push real work onto a queue.
Almost every modern provider has converged on the same wire format, now codified as the Standard Webhooks spec (signed content = id.timestamp.payload, HMAC-SHA256, base64, v1, prefix). Svix is the reference implementation of that spec, and Clerk's webhooks are Svix. Stripe uses a close variant. Once you understand one, you understand them all.
Official Documentation
| Source | URL | What it covers |
|---|---|---|
| Standard Webhooks spec | https://www.standardwebhooks.com/ | The open signature scheme, headers, replay protection |
| Svix docs | https://docs.svix.com/ | Webhook.verify(), header names, secret format |
| Stripe webhooks | https://docs.stripe.com/webhooks | Delivery, retries, event ids, raw-body requirement |
| Stripe signatures | https://docs.stripe.com/webhooks/signature | constructEvent, Stripe-Signature, tolerance |
| Clerk webhooks | https://clerk.com/docs/webhooks/overview | Clerk events delivered via Svix |
| GitHub deliveries | https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries | X-Hub-Signature-256, sha256 HMAC |
Anatomy of a delivery
An event happens on the provider's side → it serialises a JSON payload → it computes a signature over id + timestamp + payload with your shared secret → it HTTP POSTs the payload plus signature headers to your URL → your endpoint verifies, processes, and replies 2xx → the provider marks the delivery acknowledged. Any other reply (or a timeout) triggers a retry.
The signature headers carry everything verification needs. Under the Standard Webhooks / Svix scheme:
| Header | Meaning |
|---|---|
webhook-id / svix-id | Unique message id — your idempotency key |
webhook-timestamp / svix-timestamp | Unix seconds when sent — used for replay protection |
webhook-signature / svix-signature | Space-separated v1,<base64 hmac> values |
Stripe folds all three into one Stripe-Signature header (t=<ts>,v1=<hex>); GitHub sends X-Hub-Signature-256: sha256=<hex>. Same idea, different packaging.
The receiver pattern: verify → ACK fast → enqueue slow work
The single most important architectural decision: do not do real work inside the request. Verify, persist a dedupe record, return 2xx, and hand the heavy lifting (DB writes, emails, fulfilment) to a background queue. This keeps you under the provider's timeout and means a slow downstream service never causes a retry storm.
Notice a duplicate still returns 200 — you have already done the work, so you just acknowledge and move on. Only a bad signature or stale timestamp earns a 4xx.
Signature verification
This is non-negotiable. An unverified webhook endpoint is a public, unauthenticated POST handler that can mutate your database. Three rules:
- Always verify against the RAW request body. JSON-parse-then-re-stringify changes bytes and breaks the HMAC. Disable body parsing on the route (Next.js App Router gives you the raw body via
await req.text()). - Use a constant-time comparison (
crypto.timingSafeEqual) — never===— to avoid timing attacks. - Keep the secret server-side only. It is as sensitive as a password.
Standard Webhooks / Svix (Clerk uses this)
The svix package implements the spec; pass it the raw body and the three headers. verify() throws on any failure (bad signature or stale timestamp — Svix enforces a ±5 minute tolerance internally), and returns the parsed payload on success.
// app/api/webhooks/clerk/route.ts (Next.js App Router)
import { Webhook } from "svix";
const SECRET = process.env.CLERK_WEBHOOK_SECRET!; // "whsec_..."
export async function POST(req: Request): Promise<Response> {
const payload = await req.text(); // RAW body — do not JSON.parse first
const headers = {
"svix-id": req.headers.get("svix-id") ?? "",
"svix-timestamp": req.headers.get("svix-timestamp") ?? "",
"svix-signature": req.headers.get("svix-signature") ?? "",
};
let evt: { type: string; data: unknown };
try {
// Verifies HMAC-SHA256 over `${id}.${timestamp}.${payload}` AND the
// timestamp tolerance in one call. Throws on any mismatch.
evt = new Webhook(SECRET).verify(payload, headers) as typeof evt;
} catch {
return new Response("invalid signature", { status: 400 });
}
// evt is trusted from here. ACK fast, enqueue the rest.
await enqueue(headers["svix-id"], evt);
return new Response("ok", { status: 200 });
}Stripe (constructEvent)
Stripe verifies and parses in one call. It requires the raw body string/Buffer, the Stripe-Signature header, and the endpoint's signing secret (whsec_...). It also enforces a default 5-minute timestamp tolerance, so replay protection is built in.
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const ENDPOINT_SECRET = process.env.STRIPE_WEBHOOK_SECRET!; // "whsec_..."
export async function POST(req: Request): Promise<Response> {
const body = await req.text(); // raw — required for signature check
const sig = req.headers.get("stripe-signature") ?? "";
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, ENDPOINT_SECRET);
} catch (err) {
return new Response(`Webhook Error: ${(err as Error).message}`, { status: 400 });
}
await enqueue(event.id, event); // event.id is your dedupe key
return new Response("ok", { status: 200 });
}Raw HMAC-SHA256 (the underlying primitive — GitHub / M-Pesa-style)
When a provider has no SDK, verify the HMAC yourself. This is exactly what GitHub's X-Hub-Signature-256 needs. The key move is timingSafeEqual.
import { createHmac, timingSafeEqual } from "node:crypto";
/** Standard-Webhooks-style signed content: `${id}.${timestamp}.${body}`. */
export function verifyHmac(opts: {
raw: string; // raw request body
id: string; // webhook-id header
timestamp: string; // webhook-timestamp header (unix seconds)
signature: string; // base64 HMAC (strip any "v1," prefix first)
secretBase64: string;
toleranceSec?: number;
}): boolean {
const tolerance = opts.toleranceSec ?? 300; // 5 min default
const age = Math.abs(Date.now() / 1000 - Number(opts.timestamp));
if (!Number.isFinite(age) || age > tolerance) return false; // replay guard
const key = Buffer.from(opts.secretBase64, "base64");
const signed = `${opts.id}.${opts.timestamp}.${opts.raw}`;
const expected = createHmac("sha256", key).update(signed).digest(); // Buffer
const given = Buffer.from(opts.signature, "base64");
// Lengths must match before timingSafeEqual, and compare in constant time.
return expected.length === given.length && timingSafeEqual(expected, given);
}GitHub differs slightly: it signs only the raw body (no id/timestamp), uses hex encoding, and prefixes with
sha256=. ComputecreateHmac("sha256", secret).update(raw).digest("hex"), prependsha256=, andtimingSafeEqualagainstX-Hub-Signature-256.
Idempotency: dedupe on the event id
Because delivery is at-least-once, design every handler so that processing the same event twice has the same effect as processing it once. The cleanest way: a unique constraint on the event id, and let the database reject the duplicate.
import { createClient } from "@supabase/supabase-js";
const db = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
/** Returns true if this is the FIRST time we have seen this id. */
async function claimEvent(eventId: string, type: string): Promise<boolean> {
const { error } = await db
.from("webhook_events") // PK / unique on event_id
.insert({ event_id: eventId, type, received_at: new Date().toISOString() });
if (error?.code === "23505") return false; // unique_violation → already handled
if (error) throw error;
return true;
}
async function enqueue(eventId: string, evt: { type: string }): Promise<void> {
if (!(await claimEvent(eventId, evt.type))) return; // duplicate → noop, still ACK
// ...push to your real queue / do the work...
}If you cannot use a DB unique constraint, fall back to a short-TTL cache (Redis SET key NX EX 86400) keyed on the id. Either way: the id is the dedupe key, not the payload contents.
Retries & at-least-once delivery
Providers retry when you don't return 2xx in time. Knowing the schedule helps you reason about duplicates and reconciliation:
| Provider | Retry behaviour |
|---|---|
| Stripe | Exponential backoff up to ~3 days (live); a few hours in sandbox. New signature/timestamp per attempt. |
| Svix / Clerk | Exponential backoff over ~24h, then the endpoint may be disabled. |
| GitHub | Limited retries; redeliverable manually from the UI/API. |
| M-Pesa/Daraja | Effectively single-shot — design a polling/reconciliation fallback (see below). |
Implications: return 2xx only after you've durably accepted the event (dedupe row written / queued). If your worker fails after you ACKed, the provider won't retry — your queue's own retry must cover it.
Replay-attack protection
A captured-and-replayed request has a valid signature, so the signature alone can't stop it. The defence is the timestamp: reject anything outside a tolerance window (commonly ±5 minutes). Svix and Stripe enforce this for you; in raw HMAC code you do it yourself (see verifyHmac above). Combined with idempotency, a replay either fails the freshness check or is deduped as a known id.
Ordering caveats
Webhooks are not ordered. customer.subscription.updated can arrive before ...created; a payment callback can land before the STK Push response you're still writing. Never assume sequence. Defences:
- Make handlers commutative where possible (upsert by entity id, not blind insert).
- For state machines, fetch the current object from the provider's API on receipt rather than trusting the event body's snapshot.
- Use the event timestamp to discard stale updates (last-write-wins by
event.created).
Local testing
Webhook callbacks must hit a public HTTPS URL, so localhost:3000 alone won't work. Two routes:
# 1. Tunnel localhost to a public HTTPS URL
ngrok http 3000
# → https://abc123.ngrok-free.app (use this as the webhook URL in the provider dashboard)
# 2. Provider CLIs forward events straight to localhost (no tunnel, auto-signed)
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger payment_intent.succeeded # fire a test event
gh webhook forward --repo owner/repo --url http://localhost:3000/api/webhooks/githubThe Stripe CLI prints a whsec_... for the listen session — use that secret locally, not your live endpoint secret.
Observability & debugging
- Log the delivery id + event type + result for every request — that's your audit trail and dedupe evidence.
- Most dashboards (Stripe, Svix/Clerk, GitHub) show per-delivery request/response bodies and a Resend/Redeliver button — your first debugging stop.
- A flood of retries almost always means: returning non-2xx, timing out (doing work inline), or a body-parsing middleware corrupting the raw body before verification.
401/400on every delivery → wrong secret, or you verified against parsed (not raw) body.
Security checklist
- Verify the signature on every request before trusting any field.
- Verify against the raw body (no JSON parse before verification).
- Use
timingSafeEqual, never===, for signature comparison. - Enforce a timestamp tolerance (±5 min) for replay protection.
- Be idempotent — unique constraint / cache on the event id.
- ACK in <1s; push slow work to a queue.
- Return
2xxonly; rely on the queue's retry for downstream failures. - Keep the signing secret server-side, in env vars, never in client code or git.
- Webhook endpoint is HTTPS only.
- Rotate secrets if leaked; scope one secret per endpoint.
codeAmani notes
- Secrets server-side only.
STRIPE_WEBHOOK_SECRET,CLERK_WEBHOOK_SECRET, M-Pesa consumer credentials —.env.locallocally, Vercel env vars in prod. NeverNEXT_PUBLIC_*. They never touch the browser bundle. - Clerk = Svix. Use the
svixWebhook.verify()flow above for Clerk events (user.created,organization.created, …) — verify withCLERK_WEBHOOK_SECRET. - M-Pesa / Daraja callbacks are the special case. Daraja does not sign callbacks, so signature verification isn't available. Instead:
- Verify the source — restrict the callback route to Safaricom's callback IP ranges (allowlist at the edge / middleware) and only accept POSTs to the exact secret-ish callback path you registered.
- Dedupe on
CheckoutRequestID— store it the moment you get the STK Push response, then dedupe the C2B/STK callback on the same id. This is your idempotency key, exactly like an event id. - ACK in <1s — return the Daraja-expected
{ "ResultCode": 0, "ResultDesc": "Accepted" }immediately, then reconcile the payment out of band. Daraja effectively delivers once, so pair every STK Push with a status-query / reconciliation job rather than relying on the callback alone.
- ngrok for local callbacks.
ngrok http 3000, register the HTTPS URL as your Daraja callback, and test against the sandbox shortcode174379/ test phone254708374149before going live. - Route layout (matches the house structure):
app/api/webhooks/<provider>/route.tsfor Stripe/Clerk/Sentry/GitHub,app/api/mpesa/callback/route.tsfor Daraja.