Verify Signatures, Be Idempotent, Reply Fast
Receiving Webhooks
Webhooks are someone else's API calling you. Treat them with the same paranoia as user input.
What you'll learn
- Build a webhook endpoint
- Verify HMAC signatures
- Handle duplicates idempotently
A webhook is an HTTP request someone else makes to your server when something happens on their side — Stripe charge succeeded, GitHub PR opened, Twilio message delivered.
Three rules:
- Verify the signature — anyone could call your endpoint
- Be idempotent — providers retry, duplicates happen
- Reply fast — slow webhooks get marked as failed
The Signature Trap
A webhook endpoint that just does app.use(express.json()) first
throws away the bytes you need to verify the signature. Use
express.raw on webhook routes:
import express from "express";
import crypto from "node:crypto";
app.post("/webhooks/stripe",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.headers["stripe-signature"];
const body = req.body; // Buffer, not parsed
const expected = computeStripeSignature(body, process.env.STRIPE_WEBHOOK_SECRET);
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.status(400).end();
}
const event = JSON.parse(body.toString("utf8"));
// ... handle event
res.status(200).end();
}
); In real code, use the provider’s SDK (stripe.webhooks.constructEvent)
— it handles the signature algorithm, version, replay attacks, etc.
timingSafeEqual
Never compare signatures with ===. The string compare returns
faster on a mismatch in the early bytes, which lets attackers brute-
force the signature byte-by-byte. timingSafeEqual runs in constant
time.
Idempotency
Providers retry on timeouts. The same event arrives multiple times. Track event IDs:
app.post("/webhooks/stripe", express.raw(...), async (req, res) => {
const event = stripe.webhooks.constructEvent(req.body, req.headers["stripe-signature"], SECRET);
// dedupe by event id
const seen = await db.webhookEvents.findById(event.id);
if (seen) return res.status(200).end(); // already processed
await db.webhookEvents.create({ id: event.id, type: event.type });
// process — may be retried, but the `seen` check makes that safe
await processEvent(event);
res.status(200).end();
}); Reply Fast — Push Heavy Work To A Queue
Webhooks have aggressive timeouts (5-30s, depending on provider). If you do anything slow, the provider marks it failed and retries.
The right pattern: verify, enqueue, reply 200:
app.post("/webhooks/stripe", express.raw(...), async (req, res) => {
const event = stripe.webhooks.constructEvent(req.body, req.headers["stripe-signature"], SECRET);
// queue actual processing
await webhookQueue.add(event.type, { eventId: event.id });
res.status(200).end();
}); A worker handles the slow part. The provider gets a fast 200.
Sending Webhooks
The flip side — when you provide webhooks to others:
- Sign every payload (HMAC)
- Include a timestamp (prevent replay)
- Retry on failure (exponential backoff, ~24h)
- Provide a re-delivery API
That’s its own chapter — for now, focus on receiving them safely.
End of Chapter
Data and beyond-REST done. Next chapter: production — tests, logs, monitoring, deploys.
Testing →