DEV Community

Mihir kanzariya
Mihir kanzariya

Posted on

Idempotent affiliate commissions on Stripe: stop double-paying on webhook retries

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),
  });
}
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode
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);
Enter fullscreen mode Exit fullscreen mode

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

  1. 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.
  2. Key on the event, not the invoice. One invoice legitimately produces several events over its life (invoice.paid, later charge.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)