Published August 2026 by Batteries Included
You shipped a /webhooks/stripe route. Checkout works in test mode. Then a timeout, a deploy, or a dashboard “resend” fires the same payment_intent.succeeded again. Your handler grants access twice, sends two receipts, or marks one invoice paid twice.
That is not a Stripe bug. Most serious providers deliver webhooks at least once. Retries are how they recover when your endpoint is slow, returns non-2xx, or drops the acknowledgement.
This article is a production playbook for receiving webhooks on a TypeScript Node.js API: verify authenticity, acknowledge quickly, and make side effects safe under duplicates and out-of-order delivery. Platform claims link to Stripe, Resend, Svix, Twilio SendGrid, and related primary docs. Sections labeled how we work are Batteries Included delivery practice. They are not quotations from those vendors.
Scope: Inbound HTTPS webhooks from payment and email providers into your server. Examples lean Stripe and Resend (Svix-signed). The same pattern applies to other vendors with signed deliveries. This is not a full billing product design, not a NestJS course, and not a substitute for keeping provider secret keys off the client. For that rule, see Secure APIs & Backend. For a concrete Fastify contact API, see our secure contact API case study.
Short answer
- Verify every request with the provider's signing scheme against the raw request body. Parsed-then-restringified JSON breaks signatures. Prefer official libraries (Stripe
constructEvent, Svix/Resend verify helpers, SendGrid Event Webhook helpers). - Treat delivery as at-least-once. Store a unique provider message or event ID before (or atomically with) business side effects. On a duplicate ID, return 2xx and do nothing harmful.
- Return 2xx quickly after you have durably accepted the event (insert or enqueue). Do slow work (emails, PDF generation, ERP sync) asynchronously. Stripe's docs say your endpoint must quickly return a successful
2xxbefore complex logic that might time out. - Map status codes on purpose. Failed signature → 4xx (do not invite endless retries of forged traffic). Transient infrastructure failure after a valid event → 5xx (you want a retry). Duplicate of an already processed event → 2xx.
- Assume order is not guaranteed. Prefer current state from the provider API (or version/
createdcomparisons) when updates can race.
If you only remember one line: retries will happen; your handler's job is to make the second delivery boring.
Who this is for
Founders and engineers wiring Stripe Checkout, subscriptions, or transactional email into a product API. Teams whose AI-assisted backend “works until production traffic and retries.” Tech leads inheriting a route that JSON.parses the body, skips verification, and updates the database in the request thread.
If payments already succeeded but your app never updated state, see our AI-built website go-live checklist and AI Software Go-Live. If a webhook signing secret was ever pasted into a client app, rotate it and put provider calls behind a server layer.
Why webhook handlers fail in production
At-least-once is the contract
Providers cannot know whether you processed an event if the TCP connection died after you wrote to the database but before they read your 200. So they retry.
- Stripe attempts delivery in live mode for up to three days with exponential backoff. Sandbox retries are shorter (Stripe documents three attempts over a few hours).
- Resend documents at-least-once delivery and a backoff schedule after failed attempts (5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours). They also note you can replay succeeded events from the dashboard when you need to reprocess.
Duplicates are expected. Designing for “exactly once HTTP POST” is the bug.
Forged POSTs look like your provider
Without verification, anyone who discovers your webhook URL can POST a fake checkout.session.completed and trigger fulfillment. Stripe's webhook docs state this explicitly: without verification, an attacker could send fake events to trigger actions like fulfilling orders or granting account access. Resend's verify docs make the same point about fake HTTP POSTs and replay of captured payloads.
The raw-body trap
Signature schemes sign exact bytes. If Express, Fastify, or Next.js parses JSON first and you verify against JSON.stringify(req.body), whitespace and key order can change and verification fails. Or worse: you skip verification because “it keeps failing in local testing.”
Stripe requires the raw body for signature verification and warns that frameworks must not manipulate it. Resend and Svix say the same: do not parse as JSON and stringify again before verify. Twilio SendGrid's Event Webhook docs warn that transforming raw bytes to a JSON string may remove characters used in the signature.
The handler shape that holds up
How we work: every inbound webhook route follows the same order. We do not invent a new pattern per vendor.
1. Read raw body + signature headers 2. Verify signature (reject 400 on failure) 3. Extract stable event / message ID 4. Insert ID into processed_events (unique constraint) - conflict → already seen → return 200 5. Enqueue work OR apply side effect in same transaction as the insert 6. Return 2xx 7. Worker does slow / flaky work (idempotent itself)
Provider Your API Queue / DB
──────── ──────── ─────────
POST /webhooks/… ────► verify signature
insert event id (unique)
enqueue job
◄──── 200 OK
worker applies
domain changeThat is the same “server owns secrets and side effects” idea as our secure contact API and Secure APIs service: the public internet hits a small, boring endpoint; durable work happens behind it.
Control 1: Verify signatures (with the right secret)
Stripe
Stripe signs events and sends a Stripe-Signature header. Verify with the official library by passing the raw body, the header, and the endpoint secret (whsec_…). Stripe recommends official libraries over hand-rolled crypto.
Common failure modes Stripe documents:
- Wrong secret (Dashboard endpoint secret versus Stripe CLI
stripe listensecret are different; both start withwhsec_). - Body already parsed by
express.json()(or equivalent) before verify. Put the webhook route on a raw-body parser, or register JSON middleware after the webhook route. - Clock skew: libraries use a default 5-minute timestamp tolerance to limit replay attacks. Keep servers on NTP. Do not set tolerance to
0(that disables the recency check).
Stripe also documents optional IP allowlisting from their published IP list. Treat allowlisting as defense in depth, not a replacement for signatures (IPs can change; signatures travel with the payload).
On each retry, Stripe generates a new signature and timestamp for that delivery attempt. Your dedupe key is still the event's stable id (for example evt_…), not the signature string.
Resend (Svix)
Resend signs webhooks with Svix-style headers: svix-id, svix-timestamp, svix-signature. Verify with resend.webhooks.verify() or the Svix library against the raw body and signing secret (whsec_…).
Resend's FAQ: use svix-id as the unique identifier for deduplication. Store processed IDs and skip duplicates. Delivery order is not guaranteed; use created_at in the payload when order matters.
Twilio SendGrid Event Webhook
SendGrid can enable a Signed Event Webhook (ECDSA) with headers such as X-Twilio-Email-Event-Webhook-Signature and X-Twilio-Email-Event-Webhook-Timestamp, and/or OAuth 2.0 for machine-to-machine checks. Their docs recommend using SendGrid's language helpers and the raw payload bytes. Prefer their helpers over reimplementing ECDSA by hand.
Status codes after verify
| Outcome | HTTP status | Why |
|---|---|---|
| Missing/invalid signature | 400 (or other 4xx) | Bad request; do not ask the provider to retry forever |
| Valid event, accepted | 2xx | Stops the retry loop for this delivery |
| Valid event, your DB/queue is down | 5xx | Ask for a retry |
How we work: failed verification is logged with enough detail to debug (which header was missing, which endpoint secret name was configured) and never with the raw secret value.
Control 2: Idempotency (dedupe before side effects)
Idempotent here means: processing the same provider event twice leaves your system in the same correct state as processing it once.
Pick the provider's ID, not a homemade hash
| Provider | Stable ID to store |
|---|---|
| Stripe | event.id (evt_…). Same across retries of that event. |
| Resend / Svix | svix-id header (unique per message; retained across retries per Svix-style delivery). |
| Others | Prefer documented delivery IDs (for example GitHub X-GitHub-Delivery). Hash the raw body only when the vendor gives you nothing stable. |
Do not confuse Stripe's outbound Idempotency-Key (for API requests you send to Stripe) with webhook dedupe. For inbound webhooks, the event ID is the key.
Enforce uniqueness in the database
A SELECT then INSERT races when two deliveries arrive at once. Use a unique constraint and an atomic insert:
CREATE TABLE processed_webhook_events ( provider TEXT NOT NULL, event_id TEXT NOT NULL, event_type TEXT, received_at TIMESTAMPTZ NOT NULL DEFAULT now(), processed_at TIMESTAMPTZ, PRIMARY KEY (provider, event_id) );
// Pseudocode: one row per provider event
const inserted = await db.insert(processedWebhookEvents)
.values({ provider: "stripe", eventId: event.id, eventType: event.type })
.onConflictDoNothing()
.returning();
if (inserted.length === 0) {
// Duplicate delivery. Acknowledge success so retries stop.
return reply.code(200).send({ received: true, duplicate: true });
}
await queue.add("stripe-event", { eventId: event.id });
return reply.code(200).send({ received: true });How we work: duplicates return 2xx without paging on-call. They are normal. Alert on signature failures, sustained 5xx, and backlog depth instead.
Make the worker idempotent too
If you acknowledge after enqueue, the worker can still run twice (queue retry). Domain updates should be safe under replay:
- Prefer “set entitlement to active for
customer_id” over “increment seats by 1.” - Store
provider_event_idon the row you mutate when helpful. - For money and subscriptions, refetch current objects from the Stripe API when ordering is uncertain; Stripe documents that snapshot handlers can retrieve the latest resource definition from the API.
Control 3: Retries, timeouts, and async work
What providers expect
Stripe: quickly return 2xx before complex logic that could time out; process with an asynchronous queue for scale; listen only to event types you need.
Resend: respond with HTTP 200 to acknowledge; retries follow their documented backoff; emails remain stored even if webhooks fail.
What to put in the request path
Safe in the HTTP handler:
- Signature verification
- Schema checks on the verified payload (type switches)
- Atomic dedupe insert
- Enqueue to a queue or
LISTEN/NOTIFY/ worker table
Risky in the HTTP handler (move out):
- Sending email or Slack
- Generating invoices or PDFs
- Calling slow third-party ERPs
- Long multi-step workflows
Timeouts create “successful but retried” ghosts
If you finish the side effect but respond after the provider's timeout, you get a second delivery. Dedupe is what saves you. Without it, you double-fulfill.
Minimal Stripe + Fastify sketch
Illustrative only. Wire secrets through environment config; never commit whsec_ values.
import Fastify from "fastify";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
const app = Fastify({
// Ensure this route sees unmodified body bytes for constructEvent
});
app.addContentTypeParser(
"application/json",
{ parseAs: "buffer" },
(_req, body, done) => {
done(null, body);
},
);
app.post("/webhooks/stripe", async (request, reply) => {
const signature = request.headers["stripe-signature"];
if (!signature || Array.isArray(signature)) {
return reply.code(400).send({ error: "missing_signature" });
}
const rawBody = request.body as Buffer;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(rawBody, signature, endpointSecret);
} catch {
return reply.code(400).send({ error: "invalid_signature" });
}
const inserted = await insertStripeEventIfNew(event.id, event.type);
if (!inserted) {
return reply.code(200).send({ received: true, duplicate: true });
}
await enqueueStripeEvent(event.id);
return reply.code(200).send({ received: true });
});Register this parser only for webhook routes (or restore JSON parsing for the rest of the app). Mixing a global buffer parser with normal JSON APIs without care will break other routes.
For Resend, swap in raw text/buffer body plus resend.webhooks.verify({ payload, headers, webhookSecret }) (or Svix wh.verify), and dedupe on svix-id.
Checklist before you call it production
- Endpoint is HTTPS in live/production mode
- Signing secret loaded from server-only config; rotated if it ever leaked
- Verification uses raw body bytes
- Failed verification returns 4xx, not 500
- Unique store of provider event/message IDs
- Duplicates return 2xx with no extra side effects
- Slow work is async; HTTP path stays short
- Workers are idempotent under their own retries
- You subscribed only to event types you handle
- You tested replay from the provider dashboard (or CLI resend) twice and confirmed one domain effect
- Alerts cover signature failure rate, handler 5xx, and queue lag
How this ties to the rest of your backend
Webhooks are one more reason the browser cannot hold provider secrets: signing secrets and Stripe restricted keys belong on the server with the rest of your secure API surface.
If your product is still a page-builder site plus a lonely contact form, harden that path first with our secure contact API case study. When payments and email events arrive, extend the same Fastify service with verified webhook routes rather than bolting unsigned handlers onto the frontend host. For demo-to-production gaps on AI-built apps, see vibe-coded demo to production hardening.
Need a webhook-safe API layer?
If Stripe or email events are already double-firing in production, or you are wiring Checkout into an AI-built backend, we can put a typed Node.js/TypeScript service in front: verified handlers, durable dedupe, and handoff your team can run.
Related
Sources
- Stripe: Receive events in your webhook endpoint (verification, retries, quick 2xx, async handling, replay protection)
- Stripe: Fix webhook signature verification errors (raw body, middleware order, CLI vs Dashboard secrets)
- Resend: Verify webhook requests
- Resend: Managing webhooks (retries, at-least-once,
svix-id, ordering) - Svix: How to verify webhooks
- Twilio SendGrid: Event Webhook security features