← Back to dashboard
webhookstoolingfresh

Webhooks Developer Guide

What is a webhook?

The real model

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.

Incoming delivery:
Receiver: Genuine, fresh, first-seen event
  • 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
HTTP response200ACK · enqueuedGenuine, fresh, first-seen. Claim the id, enqueue the slow work, and ACK 200 in under a second. The worker does the heavy lifting off-request.

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.

Text
██╗    ██╗███████╗██████╗ ██╗  ██╗ ██████╗  ██████╗ ██╗  ██╗███████╗
██║    ██║██╔════╝██╔══██╗██║  ██║██╔═══██╗██╔═══██╗██║ ██╔╝██╔════╝
██║ █╗ ██║█████╗  ██████╔╝███████║██║   ██║██║   ██║█████╔╝ ███████╗
██║███╗██║██╔══╝  ██╔══██╗██╔══██║██║   ██║██║   ██║██╔═██╗ ╚════██║
╚███╔███╔╝███████╗██████╔╝██║  ██║╚██████╔╝╚██████╔╝██║  ██╗███████║
 ╚══╝╚══╝ ╚══════╝╚═════╝ ╚═╝  ╚═╝ ╚═════╝  ╚═════╝ ╚═╝  ╚═╝╚══════╝

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:

  1. 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.
  2. 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.
  3. 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

SourceURLWhat it covers
Standard Webhooks spechttps://www.standardwebhooks.com/The open signature scheme, headers, replay protection
Svix docshttps://docs.svix.com/Webhook.verify(), header names, secret format
Stripe webhookshttps://docs.stripe.com/webhooksDelivery, retries, event ids, raw-body requirement
Stripe signatureshttps://docs.stripe.com/webhooks/signatureconstructEvent, Stripe-Signature, tolerance
Clerk webhookshttps://clerk.com/docs/webhooks/overviewClerk events delivered via Svix
GitHub deliverieshttps://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveriesX-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:

HeaderMeaning
webhook-id / svix-idUnique message id — your idempotency key
webhook-timestamp / svix-timestampUnix seconds when sent — used for replay protection
webhook-signature / svix-signatureSpace-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.

TypeScript
// 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.

TypeScript
// 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.

TypeScript
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=. Compute createHmac("sha256", secret).update(raw).digest("hex"), prepend sha256=, and timingSafeEqual against X-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.

TypeScript
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:

ProviderRetry behaviour
StripeExponential backoff up to ~3 days (live); a few hours in sandbox. New signature/timestamp per attempt.
Svix / ClerkExponential backoff over ~24h, then the endpoint may be disabled.
GitHubLimited retries; redeliverable manually from the UI/API.
M-Pesa/DarajaEffectively 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:

Bash
# 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/github

The 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/400 on 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 2xx only; 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.local locally, Vercel env vars in prod. Never NEXT_PUBLIC_*. They never touch the browser bundle.
  • Clerk = Svix. Use the svix Webhook.verify() flow above for Clerk events (user.created, organization.created, …) — verify with CLERK_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 shortcode 174379 / test phone 254708374149 before going live.
  • Route layout (matches the house structure): app/api/webhooks/<provider>/route.ts for Stripe/Clerk/Sentry/GitHub, app/api/mpesa/callback/route.ts for Daraja.