Webhook Verification
Webhooks push events to your Node API. Verify HMAC signatures, block replays, and process idempotently because vendors retry on timeout.
Search across all documentation pages
Webhooks push events to your Node API. Verify HMAC signatures, block replays, and process idempotently because vendors retry on timeout.
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:
// 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:
event.id before side effects// 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());addContentTypeParser for raw buffer on webhook route// 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");
}constructEventimport twilio from "twilio";
const valid = twilio.validateRequest(
process.env.TWILIO_AUTH_TOKEN!,
req.headers["x-twilio-signature"] as string,
fullUrl,
req.body
);| Code | Vendor behavior |
|---|---|
| 2xx | Stop retry |
| 4xx (bad sig) | Stop retry (fix config) |
| 5xx / timeout | Retry with backoff |
timingSafeEqual.| 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 |
HMAC is computed over exact bytes. JSON.stringify after parse changes whitespace.
Separate signing secret per endpoint URL in vendor dashboard.
Stripe CLI stripe listen --forward-to localhost:3000/webhooks/stripe.
Assert 400 and zero side effects in DB when header tampered.
Generally none. Design idempotent handlers; use version fields if order matters.
X-Hub-Signature-256 HMAC SHA256 - same raw body pattern.
app.useBodyParser("raw", { verify: ...}) on webhook path or dedicated middleware.
Separate route mount without JWT - signature is authentication.
Persist failed payload for manual replay after fix - do not rely only on vendor retry window.
Optional defense in depth. HMAC verify is primary; vendor IPs change.
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.
Reviewed by Chris St. John·Last updated Jul 16, 2026