All Docs
FeaturesCalmony Sanctions MonitorUpdated March 12, 2026

Fixing Stripe Webhook Replay Double-Credits (ERR-19)

Fixing Stripe Webhook Replay Double-Credits (ERR-19)

Version: v0.1.121
Severity: High — financial data integrity
File affected: src/app/api/webhooks/stripe/route.ts


Background

Stripe's webhook delivery model is "at least once": if your endpoint does not return a 2xx response within the timeout window, Stripe will replay the event — sometimes multiple times. This is a well-documented behaviour designed to ensure no events are lost.

Without an idempotency guard on the receiving end, any non-idempotent side effect (such as crediting a user's account) will be executed again on every replay.

The Bug

The checkout.session.completed handler in the Stripe webhook route called addCredits() unconditionally on every received event. There was no record of which event IDs had already been processed.

Consequence: A single payment could result in multiple credit additions if Stripe replayed the event — for example, if the server timed out during a slow database write, or if a transient error caused a non-2xx response to be returned to Stripe.

A secondary issue was also present: the sanctions sync versionId is derived from the current date. Two syncs triggered on the same day would generate duplicate version entries rather than being treated as the same sync run.

The Fix

Idempotency key check on webhook ingestion

The handler now records each processed Stripe event.id before applying any side effects. On receipt of a webhook event:

  1. Look up event.id in the processed events store.
  2. If found → return 200 OK immediately without re-processing.
  3. If not found → record event.id, then proceed with handleCheckoutCompleted.

This guarantees that addCredits() is called exactly once per unique Stripe event, regardless of how many times Stripe delivers it.

Payment status guard

As an additional safeguard, the handler now explicitly checks session.payment_status === 'paid' before crediting the user. This defends against edge cases where a checkout.session.completed event fires for a session that did not result in a captured payment.

Session-scoped idempotency key on credit transactions

The creditTransactions table now uses the Stripe sessionId as an idempotency key. This means that even if the processed-event check were somehow bypassed, the database constraint would prevent a duplicate credit row from being inserted for the same checkout session.

Checking Your Deployment

If your instance was running a version prior to v0.1.121, it is worth auditing your creditTransactions table for duplicate entries tied to the same sessionId:

SELECT session_id, COUNT(*) AS occurrences
FROM credit_transactions
GROUP BY session_id
HAVING COUNT(*) > 1;

Any rows returned indicate sessions where credits were applied more than once. These should be reviewed and corrected manually.

Summary

LayerProtection
Webhook ingestionevent.id recorded before processing; early return on duplicate
Business logicpayment_status === 'paid' check before addCredits()
DatabaseUnique constraint on sessionId in creditTransactions

All three layers together ensure that a user's credit balance is updated exactly once per payment, even under adverse network or server conditions.