Webhooks Developer Guide

Technology: webhooks · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/webhooks

Insight:

A webhook is a reverse API call — the provider POSTs an event to a public URL you own, so the whole game is proving the request is genuine before acting on it: verify the HMAC-SHA256 signature over the raw body, ACK 2xx in under a second, then do the slow work on a queue. Delivery is at-least-once, so every handler must be idempotent (dedupe on the event id) and replay-safe (reject stale timestamps). Most providers converge on the Standard Webhooks spec — Svix (and therefore Clerk) implement it, Stripe's constructEvent is a close variant, GitHub signs the body as X-Hub-Signature-256. The odd one out for codeAmani is M-Pesa/Daraja: callbacks are unsigned, so verify by source IP + dedupe on CheckoutRequestID and always pair with a reconciliation query.

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

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

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.

sequenceDiagram
    participant P as Provider
    participant E as Your endpoint
    participant Q as Queue/Worker
    P->>P: Event occurs · sign payload
    P->>E: POST /webhooks · body + signature headers
    E->>E: Verify signature + timestamp
    E->>Q: Enqueue slow work
    E-->>P: 200 OK ACK fast
    Q->>Q: Process out of band
    Note over P,E: No 2xx in time then retry with backoff

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.

flowchart TD
    A["POST arrives"] --> B{"Signature valid?"}
    B -->|"no"| R1["Return 400 · reject"]
    B -->|"yes"| C{"Timestamp fresh?"}
    C -->|"no · too old"| R2["Return 400 · replay"]
    C -->|"yes"| D{"Event id seen before?"}
    D -->|"yes"| R3["Return 200 · dedupe noop"]
    D -->|"no"| E["Store id · enqueue work"]
    E --> F["Return 200 · ACK"]

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:

Standard Webhooks / Svix (Clerk uses this)

The svix package (v2.x) 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.

Clerk's shortcut: in a Next.js app, verifyWebhook(req) from @clerk/nextjs/webhooks wraps this Svix flow, reads CLERK_WEBHOOK_SIGNING_SECRET, and pulls the headers for you — no manual svix install or header plumbing. The raw-Svix flow below shows the mechanism underneath and stays useful in non-Next runtimes. See ../clerk/CLAUDE_CODE_INTEGRATION.md.

// app/api/webhooks/clerk/route.ts  (Next.js App Router)
import { Webhook } from "svix";

const SECRET = process.env.CLERK_WEBHOOK_SIGNING_SECRET!; // "whsec_..." (renamed from CLERK_WEBHOOK_SECRET)

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=. 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.

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...
}
flowchart TD
    A["Event id arrives"] --> B["INSERT id into events table"]
    B --> C{"Unique violation?"}
    C -->|"yes · duplicate"| D["Skip work · return 200"]
    C -->|"no · first time"| E["Do work once"]
    E --> F["Return 200"]
    D --> G["Retry storm absorbed"]
    F --> G

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:


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

The Stripe CLI prints a whsec_... for the listen session — use that secret locally, not your live endpoint secret.


Observability & debugging


Security checklist


codeAmani notes

Official docs: