Webhook Verification
Webhooks push events to your Node API. Verify HMAC signatures, block replays, and process idempotently because vendors retry on timeout.
Recipe
Quick-reference recipe card - copy-paste ready.
// Express 5 - raw body for Stripe
import express from "express";
import Stripe from "stripe";
const app = express();
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.headers["stripe-signature"] as string;
try {
const event = Stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
// dispatch event.type
res.json({ received: true });
} catch {
res.status(400).send("invalid signature");
}
}
);When to reach for this:
- Stripe, Twilio, GitHub, Shopify, SendGrid POST callbacks
- You need server-to-server events without polling
Working Example
// src/webhooks/stripe.ts
import type { Request, Response } from "express";
import Stripe from "stripe";
import { getStripe } from "../integrations/stripe-client";
import { paymentsQueue } from "../queues/payments";
const stripe = getStripe();
export async function stripeWebhookHandler(req: Request, res: Response) {
const sig = req.headers["stripe-signature"];
if (!sig || !Buffer.isBuffer(req.body)) {
return res.status(400).send("bad request");
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.warn({ msg: "stripe_sig_fail", err: (err as Error).message });
return res.status(400).send("invalid signature");
}
if (await isEventProcessed(event.id)) {
return res.json({ received: true, duplicate: true });
}
await markEventReceived(event.id);
if (event.type === "payment_intent.succeeded") {
await paymentsQueue.add("fulfill", { paymentIntentId: (event.data.object as Stripe.PaymentIntent).id });
}
res.json({ received: true });
}
// Generic HMAC verification (Twilio-style)
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyHmacSha256(rawBody: Buffer, signature: string, secret: string): boolean {
const expected = createHmac("sha256", secret).update(rawBody).digest("base64");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}What this demonstrates:
- Raw body required - JSON parser breaks signature input
- Stripe helper validates timestamp tolerance internally
- Idempotency on
event.idbefore side effects - Async fulfill via queue - webhook responds fast
Deep Dive
Raw Body Middleware Order
// Mount webhook routes BEFORE express.json() globally
const webhookRouter = express.Router();
webhookRouter.post("/stripe", express.raw({ type: "application/json" }), stripeWebhookHandler);
app.use("/webhooks", webhookRouter);
app.use(express.json());- Fastify:
addContentTypeParserfor raw buffer on webhook route
Replay Protection
// Reject events older than 5 minutes (custom parsers)
const ts = Number(req.header("X-Webhook-Timestamp"));
if (Math.abs(Date.now() / 1000 - ts) > 300) {
return res.status(400).send("stale");
}- Stripe SDK handles tolerance in
constructEvent - Store processed event ids 30+ days
Twilio Request Validation
import twilio from "twilio";
const valid = twilio.validateRequest(
process.env.TWILIO_AUTH_TOKEN!,
req.headers["x-twilio-signature"] as string,
fullUrl,
req.body
);- Uses public URL including query string as Twilio signed it
Response Codes
| Code | Vendor behavior |
|---|---|
| 2xx | Stop retry |
| 4xx (bad sig) | Stop retry (fix config) |
| 5xx / timeout | Retry with backoff |
- Return 200 after durable enqueue, not after full business logic completes
Gotchas
- JSON parser before verify - signature never matches. Fix: raw route isolated.
- No idempotency - duplicate shipments on retry. Fix: event id table unique constraint.
- Slow handler - vendor retries pile up. Fix: queue worker.
- String compare signature - timing leak. Fix:
timingSafeEqual. - Wrong webhook secret env - all events 400 in prod after rotate. Fix: dual secrets during rotation window.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Polling vendor API | Webhook unavailable | Real-time needed, rate limits costly |
| SNS/SQS fan-in | AWS event ingestion | Simple single-endpoint Stripe |
| Svix relay | Multi-vendor verify UX | Single Stripe-only integration |
| mTLS partner webhooks | B2B contract requires | SaaS vendor uses HMAC |
FAQs
Why raw body?
HMAC is computed over exact bytes. JSON.stringify after parse changes whitespace.
Multiple webhook endpoints?
Separate signing secret per endpoint URL in vendor dashboard.
Local dev webhooks?
Stripe CLI stripe listen --forward-to localhost:3000/webhooks/stripe.
Test signature failure?
Assert 400 and zero side effects in DB when header tampered.
Ordering guarantees?
Generally none. Design idempotent handlers; use version fields if order matters.
GitHub webhooks?
X-Hub-Signature-256 HMAC SHA256 - same raw body pattern.
NestJS raw body?
app.useBodyParser("raw", { verify: ...}) on webhook path or dedicated middleware.
Webhook auth vs API auth?
Separate route mount without JWT - signature is authentication.
DLQ failed webhooks?
Persist failed payload for manual replay after fix - do not rely only on vendor retry window.
IP allowlist?
Optional defense in depth. HMAC verify is primary; vendor IPs change.
Related
- Idempotency Keys - duplicate events
- Stripe, Twilio & SDK Patterns - vendor SDKs
- Queues Basics - async processing
- Integration Failure Playbook - vendor down
- Integrations Best Practices - policy
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.