Indie Hackers researchers traced a recurring support headache back to a single race condition inside Stripe webhook handling: simultaneous retries hit the same business transaction twice, and nobody noticed until customers complained about double charges. The fix looks obvious on paper, yet most teams still treat webhooks like regular requests.
What happened in the Indie Hackers post
Two things lined up: a webhook that triggered a downstream billing workflow and Stripe's stubborn automatic retries. When the original webhook handler takes longer than a few hundred milliseconds, Stripe retries the exact same event with the same id and idempotency_key. If the handler is not guarding against duplicate work, the second invocation commits the same payment record and triggers the customer's card again. By the time the developer examined the logs, support tickets had piled up and a single user had been billed twice for the same plan.
The key insight: the retries are legitimate, the payload is identical, and Stripe never marks the event "completed" until your webhook returns 200. So the safe answer is to process each event exactly once, even if Stripe delivers it a dozen times.
The "obviously wrong" pattern
Here's the simplified handler that almost every starter kit ships:
app.post("/api/stripe/webhook", async (req, res) => {
const event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
if (event.type === "invoice.payment_succeeded") {
const invoice = event.data.object;
await db.payments.insert({
invoiceId: invoice.id,
amount: invoice.amount_paid,
customerId: invoice.customer,
processedAt: new Date(),
});
await queue.enqueue("deliver-license", { invoiceId: invoice.id });
}
res.status(200).send();
});
No idempotency, no locking, just another async route. If Stripe retries the same event, that insert runs again and a second charge is written. There's no shared cache or DB row that says "stop, I already handled this event."
KeelStack's atomic idempotency guard
KeelStack ships with a utility that wraps every webhook inside an atomic guard keyed on stripe_event_id + stripe_event_type. It touches one durable row in the database before any business work runs. The guard rejects duplicates in the same transaction, so you can safely acknowledge Stripe before any further work executes.
app.post("/api/stripe/webhook", async (req, res) => {
const event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
await idempotency.guard(event.id, async (ctx) => {
if (ctx.alreadyProcessed) return;
await db.payments.insert({
stripeId: event.id,
amount: event.data.object.amount_paid,
customerId: event.data.object.customer,
processedAt: new Date(),
});
await jobQueue.enqueue("deliver-license", {
invoiceId: event.data.object.id,
customerId: event.data.object.customer,
});
});
res.status(200).send();
});
The guard exposes ctx.alreadyProcessed, so duplicate deliveries short-circuit before they mutate the database or change customer state. Even under concurrent retries, the second handler hits the database conflict first and returns a clean 200 without touching the rest of the system.
Why this matters for your SaaS
- Duplicate billing kills trust faster than any other incident.
- Stripe's retries are not bugs — they are your backup plan. Treat them as a feature.
- An idempotency guard like KeelStack's gives you a reproducible, auditable safeguard that you can test locally.
The Indie Hackers race condition is still the same bug we see in every project that treats webhooks as fire-and-forget. Wrap your handler in an atomic guard, store the Stripe event ID alongside your payment rows, and your ledger stays clean even when retries are furious.
Top comments (0)