Webhook Receiver
A webhook is another company's server calling yours: Stripe telling you a payment cleared, GitHub telling you someone pushed, Shopify telling you an order landed. Your side of the deal is one public URL, and that URL has three enemies. Strangers, because anyone who finds the URL can POST fake events to it, and "anyone can tell my server a payment cleared" is a sentence you should read twice. Duplicates, because every serious webhook provider retries on timeout or error, which means delivering the same event two or three times is normal operation, not a bug. And your own slow code, because if handling the event takes longer than the sender's timeout, it marks the delivery failed and retries, and now your slowness is manufacturing duplicates.
The shape that survives all three is the same regardless of provider, and it is worth memorizing: verify the signature on the raw bytes, insert the event ID into a table with a unique constraint, return 200, and do the real work after. Verification keeps strangers out. The unique constraint is your dedupe: the database refuses the second insert, so a retried delivery becomes a no-op without any clever code. Returning 200 fast, before processing, keeps the sender's timeout from ever seeing your slow parts. Every piece of this spec is one of those three moves.
The naive version, the one an agent writes if you just say "handle the Stripe webhook," does the work inline and returns 200 at the end. It passes every test you'll think to run, because your tests don't time out and don't retry. Then production delivers a duplicate on a slow night and a customer gets charged twice, or emailed twice, or shipped twice. This is the template other integration templates on this site point at when they say "dedupe before acting." Fill in your provider; the skeleton does not change.
Prerequisites
- An account with the webhook provider ([Stripe, GitHub, Shopify, etc.]) and the endpoint's signing secret from their dashboard.
- A Postgres database (Supabase or Vercel Postgres are fine) for the events table.
- For local testing: the provider's CLI if it has one (Stripe's can replay events to localhost), or a tunnel like ngrok.
# Project: Webhook receiver for [provider]
Build an endpoint that receives [provider] webhooks for the events
listed below. The receive/verify/dedupe skeleton is fixed; only the
per-event processing is custom.
## Events to handle
- `[event.type.one]`: [what we do: e.g., mark the order paid]
- `[event.type.two]`: [what we do]
- Any other event type: verify, store, return 200, do nothing. An
unknown event is never an error; providers add types without asking.
## The skeleton (order is load-bearing)
```
receive -> verify signature -> insert event id -> return 200 -> process
```
1. Read the RAW request body bytes. Verification signs the exact
bytes sent; a parsed-then-reserialized body will not match.
2. Verify the signature using `[PROVIDER]_WEBHOOK_SECRET` from the
environment. Use the provider's official verification helper if
the SDK has one; otherwise HMAC-SHA256 over the raw body with a
timing-safe comparison, and reject timestamps older than 5
minutes to block replays. Failure: 401, log, stop. No partial
trust: an unverified payload is not data, it is input from a
stranger.
3. Extract the provider's event ID and insert into `webhook_events`.
Unique-violation on insert means we have seen this delivery:
return 200 immediately and do NOT process again. That 200 is
correct. It tells the provider to stop retrying.
4. Return 200 now, before processing. Target under 1 second.
5. Process the event after the response (queue, background function,
or a worker polling the table; pick per platform. On serverless
a dangling promise is NOT "after", the runtime freezes it).
## Events table
```sql
CREATE TABLE webhook_events (
id TEXT PRIMARY KEY, -- provider's event id
event_type TEXT NOT NULL,
payload JSONB NOT NULL, -- full raw event, always
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ, -- null until work completes
attempts INT NOT NULL DEFAULT 0,
last_error TEXT
);
```
Storing the full payload means a processing bug is recoverable: fix
the code, reprocess the stored rows, nothing lost.
## Handler sketch
```typescript
export async function POST(req: Request) {
const raw = await req.text();
if (!verifySignature(raw, req.headers)) {
return new Response("bad signature", { status: 401 });
}
const event = JSON.parse(raw);
const inserted = await db.insertIgnoreDuplicate("webhook_events", {
id: event.id, event_type: event.type, payload: event,
});
if (!inserted) return new Response("ok", { status: 200 }); // duplicate
await enqueueProcessing(event.id); // hand off; do not await the work
return new Response("ok", { status: 200 });
}
```
## Processing rules
- Each processor loads the payload from `webhook_events` by id, does
its work, sets `processed_at`.
- Processors must be idempotent anyway (use UPDATE-where-not-already
/ upserts), because "exactly once" is a direction, not a guarantee.
- On processing failure: increment `attempts`, record `last_error`,
retry with backoff up to 5 attempts, then leave the row unprocessed
and alert me ([how: Slack message, email]). Never retry forever.
- Ordering is not guaranteed. Events can arrive late, out of order,
or twice; processors decide from CURRENT stored state, not from
assumed sequence.
## Configuration
- Env vars: `[PROVIDER]_WEBHOOK_SECRET`, `DATABASE_URL`. Provide
`.env.example`. The secret never appears in code or logs.
- Log every received event: id, type, verified yes/no, duplicate
yes/no. Never log the secret or full payloads of sensitive events.
## Done means (test each one, not just the happy path)
- Valid event: 200, one row, processed once.
- Same event delivered twice: 200 both times, ONE row, processed once.
- Tampered body with real headers: 401, zero rows.
- Unknown event type: 200, row stored, no processor invoked.
- Processor throws: response was already 200, `attempts` increments,
retry happens, `last_error` is readable.Adaptation notes:
- Provider quirks slot into steps 2 and 3 only: Stripe wants their SDK's
constructEventandevt.id; GitHub signs withX-Hub-Signature-256and sendsX-GitHub-Deliveryas the id; Shopify signs base64 inX-Shopify-Hmac-Sha256. The skeleton around those two steps does not change. - Provider sends no event ID: build one as a hash of the raw body plus the delivery timestamp header, and say so in a comment. Weaker than a real id, still miles better than no dedupe.
- Low stakes and truly idempotent processing (a cache invalidation ping): you can collapse steps 4 and 5 and process inline. Decide that on purpose, in writing, not by letting the agent default to it.
- Replaying is your superpower: because payloads are stored, "reprocess events from last Tuesday" is a script, not an archaeology dig. Ask for that script in the same build; it costs ten lines now and a weekend later.
- The mistake: returning 500 when processing fails, after you already stored the event. The provider retries, your dedupe eats the retry as a duplicate, and the event silently never processes. Once the row is stored, delivery has succeeded: return 200 and let YOUR retry loop own the failure.