Stripe Webhook Best Practices in 2026 (with Code Examples)
You wired your Stripe webhook endpoint, deployed it, and shipped. A week later you're missing payments, double-fulfilling orders, or seeing 5xx alerts at 3am. Welcome — every Stripe integration goes through this.
This is a 2026-current guide to the seven things that make Stripe webhooks reliable in production. Each section has a "what most teams miss" callout based on common bugs we've seen.
1. Always verify the webhook signature
Stripe signs every webhook with a secret you set on the endpoint. Verify it server-side. Do not trust the request body alone — without verification, anyone who knows your endpoint URL can POST fake events and trigger your business logic.
Node.js (Express) example:
import Stripe from "stripe";
import express from "express";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.headers["stripe-signature"];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
signature,
process.env.STRIPE_WEBHOOK_SECRET,
);
} catch (err) {
console.error("Signature verification failed:", err.message);
return res.status(400).send("Invalid signature");
}
// event is now trusted
handleEvent(event);
res.json({ received: true });
},
);
What most teams miss: the body must be the raw bytes. If you've already parsed it as JSON, signature verification fails. In Express, use express.raw({ type: "application/json" }) for the webhook route specifically, not express.json().
2. Make every handler idempotent
Stripe will deliver the same event multiple times — on retries, on accidental redelivery from the dashboard, sometimes for no obvious reason at all. Your handler must produce the same result every time.
The simplest idempotency pattern: store event.id after processing and skip if already seen.
async function handleEvent(event) {
const seen = await db.query(
"SELECT 1 FROM processed_webhooks WHERE event_id = $1",
[event.id],
);
if (seen.rows.length > 0) return;
await db.transaction(async (tx) => {
// your business logic here
if (event.type === "checkout.session.completed") {
await fulfillOrder(tx, event.data.object);
}
// record idempotency key INSIDE the same transaction
await tx.query(
"INSERT INTO processed_webhooks (event_id, processed_at) VALUES ($1, NOW())",
[event.id],
);
});
}
What most teams miss: the idempotency record and the business work need to be in the same transaction. Otherwise a crash between the two leaves you in a state where you've fulfilled but not recorded — next retry will double-fulfill.
3. Respond fast — process async
Stripe expects a response within a short window. If your endpoint takes too long, Stripe records the request as failed and queues a retry. Synchronous heavy work (sending emails, hitting third-party APIs) creates this exact failure mode.
Pattern: validate, persist the event, and return 200 immediately. Process asynchronously.
app.post("/webhooks/stripe", express.raw(...), async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(...);
} catch (err) {
return res.status(400).send("Invalid signature");
}
// Persist event for async worker to pick up
await db.query(
"INSERT INTO webhook_queue (event_id, payload, status) VALUES ($1, $2, 'pending') ON CONFLICT DO NOTHING",
[event.id, event],
);
// ACK immediately — worker will process via queue
res.status(200).json({ received: true });
});
A separate worker (Inngest, Trigger.dev, BullMQ, Postgres LISTEN/NOTIFY, whatever fits) handles the heavy lifting. Stripe sees a fast 200 and stops retrying.
What most teams miss: if persistence to the queue fails, you've already lost the event. Either use a database transaction that includes both the queue insert and the response, or rely on Stripe's redelivery (return 5xx if persistence fails).
4. Handle Stripe's retry behavior explicitly
If your endpoint returns 4xx or 5xx, Stripe retries with exponential backoff for up to 3 days. This is helpful for transient errors, but it can mask permanent failures — you'll see the same broken event retried hundreds of times.
Categorize failures:
- Transient (return 5xx): database is down, dependent API is unreachable. Stripe retries.
- Permanent (return 200 + log): malformed metadata you can't recover from, an order ID that never existed. Don't retry — log and move on.
async function handleEvent(event) {
try {
await processEvent(event);
} catch (err) {
if (isTransient(err)) {
throw err; // bubbles to 5xx — Stripe retries
}
// permanent failure — log and ACK
console.error("Permanent webhook failure:", event.id, err);
await db.query(
"INSERT INTO webhook_dlq (event_id, error, created_at) VALUES ($1, $2, NOW())",
[event.id, err.message],
);
// returning normally produces a 200
}
}
What most teams miss: treating every error as transient. After 3 days of failed retries, Stripe gives up and the event is gone forever. If your error is permanent, ACK immediately and move it to a DLQ for manual review.
5. Log enough to debug six months later
When something goes wrong, you'll be looking at logs. Make sure they have enough context to actually answer "what happened with this customer's payment on this date?"
Minimum log fields:
event.idevent.type- Stripe object ID (e.g.,
customer.id,invoice.id) - Outcome (succeeded / retried / failed / skipped-as-duplicate)
- Latency from receipt to ACK
Don't log full payloads to your standard log stream — they contain customer email, names, and partial card data, all of which is sensitive. Keep raw payloads in a separate audit store with stricter retention.
6. Test before you deploy
Stripe's test mode is great, but it doesn't help when you're iterating on your webhook handler logic itself. You need to be able to:
- Capture a real Stripe event payload
- Replay it against your local handler as you fix bugs
- Inspect what arrived without spinning up a tunnel
The classic stack: ngrok for the tunnel, Stripe CLI for triggering test events, manual logging for inspection. This works but it's slow when you're iterating.
A faster loop:
- Use Stripe CLI (
stripe trigger checkout.session.completed) to fire test events - Point them at a HookRay URL to capture and inspect the raw payload
- Replay the captured request against your local handler with one click — no need to re-trigger from Stripe each time you fix a bug
HookRay gives you instant webhook URLs, real-time inspection, search across captured requests, and one-click replay against any endpoint. Free tier covers 100 requests/month — enough for active development.
For the dedicated Stripe testing flow, see our Stripe webhook testing guide.
7. Plan for the "we missed events" recovery
Sometimes events are missed — your endpoint was down, the queue worker crashed and lost data, you deployed a buggy version. You'll need to recover.
Stripe's events.list API lets you fetch historical events:
const events = await stripe.events.list({
type: "checkout.session.completed",
created: { gte: 1745000000 }, // unix timestamp
limit: 100,
});
for (const event of events.data) {
await handleEvent(event); // idempotency from #2 protects against re-processing
}
Because you wrote idempotent handlers (#2), running this recovery script over a window is safe — already-processed events are skipped.
What most teams miss: they don't have a recovery script ready until they need it at 2am during an outage. Write it on day one and put it next to the webhook handler.
Quick reference checklist
- Signature verified server-side with raw body
- Idempotency record + business work in same transaction
- Heavy work moved off the request path (async queue)
- 5xx for transient, 200 + DLQ for permanent failures
- Logs include event.id, type, object id, outcome, latency
- Local testing via Stripe CLI + capture tool + replay loop
- Recovery script using
events.listready before you need it
When you're stuck
The most common debugging questions in 2026:
- "My signature verification fails locally but passes in production" → you're parsing the body before verifying. Use raw body middleware on the webhook route only.
- "Same event is being processed twice" → idempotency record is outside the transaction. Move it in.
- "Stripe retries forever even though I'm returning 200" → you're returning 200 but throwing afterward, or the response is being sent before async work completes. Add explicit
await res.send()patterns or useres.json(...).end(). - "I don't see webhook events in my logs" → Stripe webhook log shows delivery attempts. Check
https://dashboard.stripe.com/webhooksfor your endpoint's "Recent attempts" tab.
Want a free way to capture and replay Stripe webhooks during development? HookRay gives you instant URLs, real-time inspection, and one-click replay — no signup required for the free tier.
Ready to test your webhooks?
Get a free webhook URL in 5 seconds. No signup required.
Start Testing — Free