Back to Articles
5 min

Payment Idempotency Is Not Optional: Lessons from Production Webhooks

Why duplicate webhook events aren't an edge case, the idempotency-log pattern that fixes it, and how Stripe Connect's charge models change who's merchant of record.

BackendStripeWebhooks

The first duplicate webhook event doesn't look like a bug. It looks like a support ticket — a customer charged twice, an order fulfilled twice, a payout sent twice. By the time it reaches you, the actual cause is three systems removed from the symptom.

Here's the part that's easy to miss: duplicate delivery isn't a failure mode. It's the contract.

"At least once" means at least once

Stripe, like every serious webhook provider, guarantees at-least-once delivery, not exactly-once. If your endpoint doesn't respond with a 2xx inside the timeout window — because your server was slow, because a deploy was mid-restart, because the network blipped — Stripe assumes the event didn't arrive and retries it. On a backoff schedule, for hours.

This means every one of these scenarios produces a duplicate event on your end, even though your server processed the first one correctly:

  • Your handler finished the work but the response was slow enough to time out
  • A load balancer dropped the response on the way back
  • Your process restarted between finishing the work and sending the 200

None of these are edge cases. On any endpoint with real traffic, they happen every week. If your webhook handler assumes "one event, one call," you will eventually double-charge a card, double-decrement inventory, or double-send a payout — not because your code is wrong, but because you built it for a guarantee the provider never made.

The idempotency-log pattern

The fix is small and unglamorous: before you do anything with an event, check whether you've already seen it. Key the check on the provider's event ID, not on anything derived from the payload.

// idempotency.ts
async function alreadyProcessed(eventId: string): Promise<boolean> {
  const existing = await db.processedEvent.findUnique({ where: { eventId } });
  return existing !== null;
}
 
async function markProcessed(eventId: string, eventType: string) {
  await db.processedEvent.create({ data: { eventId, eventType, processedAt: new Date() } });
}
// webhook handler
export async function POST(req: Request) {
  const event = await verifyStripeSignature(req);
 
  if (await alreadyProcessed(event.id)) {
    // Not an error. This is the pattern working as intended.
    return new Response(null, { status: 200 });
  }
 
  await handlers[event.type]?.(event.data.object);
  await markProcessed(event.id, event.type);
 
  return new Response(null, { status: 200 });
}

Three details that matter more than the happy path:

The duplicate check has to happen before any side effect runs, not after. If you mark an event processed at the end of the handler, a slow request that times out and gets retried mid-execution will run the business logic twice before either call finishes.

A duplicate is a 200, not a 409 or a retry signal. Stripe interprets any non-2xx as "please try again." Returning an error on a duplicate just guarantees a third delivery.

The idempotency table needs a unique constraint on eventId, not just application-level checking. Two retries can arrive close enough together that a findUniquecreate check-then-act sequence races itself. Let the database reject the second insert; catch that specific error and treat it the same as "already processed."

This is the same shape as the Locking Module pattern MedusaJS uses for inventory reservations, or Stripe's own Idempotency-Key header for outbound API calls — you're not inventing a new idea, you're applying "make retries safe" in the one place most handlers skip it: inbound events.

Stripe Connect: three charge models, three different answers to "who's the merchant"

Idempotency handles duplicate events. The other production question — usually asked later, and more expensive to get wrong — is who is actually the merchant of record when a marketplace or platform sits between the buyer and the seller. Stripe Connect gives you three charge models, and they don't just move money differently. They change who's legally on the hook.

Direct charges. The connected account is the merchant of record. The charge appears on their Stripe dashboard, their statement descriptor, their dispute liability. The platform takes an application fee off the top. This is the right model when each seller is genuinely an independent business — a marketplace of vendors, for example — because disputes, refunds, and tax reporting route to the party who actually delivered the goods.

Destination charges. The platform is the merchant of record; the charge is created on the platform's account and a portion is transferred to the connected account as a transfer_data.destination. The platform's name is what the cardholder sees on their statement. This is the common choice when the platform wants a unified checkout experience and is willing to own dispute handling centrally — but it also means the platform absorbs the operational weight of every chargeback, even ones caused entirely by the connected account's product.

Separate charges and transfers. The platform charges the customer directly with no transfer_data at all, then moves money to connected accounts as a distinct, later step via the Transfers API. This decouples the payment from the payout in time — useful when payout timing depends on business logic Stripe doesn't know about (an order being marked delivered, a return window closing, a multi-vendor cart needing to be split after the fact). It also means you own the reconciliation between "money collected" and "money owed to each account," which is exactly the kind of state that needs the idempotency discipline above, since a retried transfer is just as capable of double-paying a vendor as a retried charge is of double-charging a customer.

The model you pick isn't just a payments-API decision. It decides whose name is on the statement descriptor, who fields the dispute, and — in a regulated or multi-party marketplace — who's actually allowed to be the merchant of record in the first place. That question is worth answering before the first charge runs, not after the first dispute forces the conversation.

The pattern, not the incident

None of this requires a war story to be true. It requires treating "the event fired twice" as a certainty you design for, not a bug you patch after the fact — and treating "who's the merchant of record" as an architecture decision, not a payments-API afterthought decided by whichever charge_type example you copied first.