Stripe retries webhooks on purpose. If your endpoint times out or returns a 500, Stripe redelivers the same event, sometimes for up to three days. And even when nothing fails, Stripe's delivery is at-least-once, so the same event.id can land twice.
If your affiliate commission logic runs on invoice.paid and inserts a commission row every time it sees the event, one payment can pay an affiliate two, three, or five times. I hit this early while building Referralful (affiliate software for SaaS on Stripe). The fix is boring and it holds under load: make the commission write idempotent on the Stripe event.
Why retries happen more than you expect
- Your handler threw during a deploy.
- A downstream call (email, Slack, an analytics ping) timed out and you did not isolate it from the payout write.
- Stripe redelivered on its own, no failure on your side at all.
You cannot stop redelivery. You can make processing the same event twice a no-op.
The version that double-pays
// runs on every invoice.paid
const invoice = event.data.object;
const ref = await findReferral(invoice.customer);
if (ref) {
await db.commissions.insert({
affiliate_id: ref.affiliate_id,
invoice_id: invoice.id,
amount_cents: Math.round(invoice.amount_paid * ref.rate),
});
}
Two deliveries of the same event, two rows, double payout. No error is thrown, so nothing warns you. You find out when an affiliate emails asking why their balance looks high.
The fix: a unique key on the event
Store the Stripe event.id with a unique constraint and let the database reject the duplicate.
create table commissions (
id bigserial primary key,
stripe_event text not null,
invoice_id text not null,
affiliate_id text not null,
amount_cents bigint not null,
created_at timestamptz default now(),
unique (stripe_event)
);
try {
await db.commissions.insert({
stripe_event: event.id,
invoice_id: invoice.id,
affiliate_id: ref.affiliate_id,
amount_cents: Math.round(invoice.amount_paid * ref.rate),
});
} catch (e) {
if (e.code === '23505') return res.sendStatus(200); // duplicate, already recorded
throw e;
}
res.sendStatus(200);
The unique constraint does the concurrency work for you. Two deliveries racing in the same millisecond: one wins the insert, the other hits 23505 and returns 200 so Stripe stops retrying.
Two things people get wrong
- Return 200 on the duplicate, not 500. Throw on it and Stripe keeps retrying, your error tracker fills with false alarms, and you start ignoring real ones.
- Key on the event, not the invoice. One invoice legitimately produces several events over its life (
invoice.paid, latercharge.refunded). You want each event processed once, not each invoice.
That is the whole pattern. No distributed lock, no Redis, no "did we already handle this" lookup that races under load. One column and one constraint.
I build this for a living, so fair disclosure: https://referralful.com/?utm_source=dev.to&utm_medium=article&utm_campaign=engagement. But the idea is generic. Any time money moves on a webhook, make the write idempotent on the provider's event id, and let a unique constraint be the referee.
Top comments (0)