ThisCalculator

Everyday

One Payment Event Two Credit Grants: Webhook Bug Fix

Updated · Standard formula, shown below

If your payment system fires one webhook event but your code creates two credit grants, the fix is to make your webhook handler idempotent by keying on the payment event’s unique ID. This article explains exactly why the duplicate happens, how to fix it in TypeScript, and how to prevent it from recurring.

Why Duplicate Webhook Processing Happens

Payment providers (Stripe, PayPal, etc.) guarantee at least once delivery of webhook events. That means the same event can be sent multiple times due to network timeouts, retries, or provider-side redundancy. If your handler simply processes every incoming event without checking whether it has already been handled, you’ll end up with duplicate side effects—in your case, two credit grants for one payment.

The most common cause is a race condition: two identical webhook requests arrive nearly simultaneously, and both pass your “has this been processed?” check before either has written the result to the database. Another cause is a missing unique constraint on the event ID in your database, allowing two rows to be inserted even if you intended to check first.

The Core Fix: Idempotency Keys

Idempotency means that processing the same event multiple times produces the same result as processing it once. For webhooks, the standard pattern is:

  1. Extract the event ID from the webhook payload (e.g., event.id for Stripe).
  2. Before applying any side effects, attempt to insert a record of that event ID into a dedicated table (e.g., processed_events) with a unique constraint.
  3. If the insert succeeds, you are the first to process this event—go ahead and grant the credit.
  4. If the insert fails due to a duplicate key, another request is already processing (or has processed) this event—skip it.

In TypeScript with a SQL database (PostgreSQL, MySQL), this looks like:

async function handlePaymentEvent(event: PaymentEvent) {
  try {
    await db.insert({ id: event.id }).into('processed_events');
  } catch (err) {
    if (isDuplicateKeyError(err)) {
      console.log('Event already processed, skipping');
      return;
    }
    throw err;
  }
  // Now safe to grant credits
  await grantCredits(event.customerId, event.amount);
}

The unique constraint on processed_events.id is what makes this race-safe. Even if two requests hit at the same time, only one insert will succeed.

Alternative: Check-and-Set with a Transaction

If you cannot use a separate table, you can use a transaction with a conditional update. For example, if your user table has a last_processed_event_id column, you can do:

await db.transaction(async (trx) => {
  const user = await trx('users').where('id', customerId).forUpdate().first();
  if (user.last_processed_event_id === event.id) return;
  await trx('users').where('id', customerId).update({
    credits: user.credits + amount,
    last_processed_event_id: event.id
  });
});

The forUpdate() locks the row, ensuring that only one request can read and update at a time. This is simpler but less scalable than the dedicated table approach.

Common Pitfalls When Implementing the Fix

  • Not using a unique constraint: If you just check whether the event ID exists before inserting, two concurrent requests can both see “not exists” and both proceed. The unique constraint is the only reliable guard.
  • Ignoring provider-specific fields: Some providers send the same logical event with a different ID on retry (rare but possible). Always use the provider’s documented event ID, and if they don’t provide one, construct your own from the event type + timestamp + object ID.
  • Handling the catch block too broadly: Catching all errors and returning success can hide real failures. Only catch duplicate key errors; let others bubble up so the provider can retry.
  • Forgetting to commit the event insert atomically: If you insert the event ID and then the credit grant fails, you’ll have a processed_event without a credit. Make both operations part of the same transaction, or handle compensation.

Testing Your Fix

To verify your fix works, simulate a duplicate delivery. Most payment providers have a “resend event” or “redeliver” feature in their dashboard. Use that to send the same event twice. Your logs should show the second attempt being skipped.

For automated tests, write a unit test that calls your handler twice with the same event object and asserts that the credit grant function is called only once. Also test concurrency by firing both requests with Promise.all—the unique constraint should ensure only one succeeds.

When Not to Use This Pattern

If your webhook handler is not idempotent and you have no way to add a unique constraint (e.g., a legacy NoSQL database without unique indexes), you may need to use a distributed lock (like Redis) or switch to a queue worker that processes events one at a time. However, the unique-constraint approach is the simplest and most robust for most TypeScript backends.

Frequently Asked Questions

Why does my webhook fire twice even though I only paid once?

Payment providers retry webhooks that fail to return a 2xx response within a timeout. If your handler is slow or returns an error, the provider will send the same event again later. Even if your handler returns 200, some providers may still send duplicates due to their own internal retries. The idempotency key pattern handles all these cases.

Should I store the event ID in the same database as my users?

Yes, it’s fine to store it in the same database. Use a separate table (e.g., processed_events) with a unique constraint on the event ID. This is the most common and reliable approach.

What if the credit grant itself fails after I insert the event ID?

You should wrap the event ID insert and the credit grant in a single database transaction. If the credit grant fails, the transaction rolls back, and the event ID insert is undone, so the event can be retried. If you can’t use a transaction, you’ll need a compensation mechanism or a retry queue.

Can I just check if the user already has a credit from this payment?

That’s a valid alternative if the credit has a unique reference to the payment ID. For example, if you store a payment_id column on the credit record with a unique constraint, then attempting to insert a second credit with the same payment ID will fail. This works but is less generic than the event ID table.

Does this fix work with Stripe’s webhook signature verification?

Yes, signature verification is separate from idempotency. You should still verify the signature to ensure the request is from Stripe, and then use the event ID for idempotency. Both are necessary for a secure and correct implementation.

What about events that are not idempotent, like refunds?

The same pattern applies. Refund events should also be processed idempotently. The event ID table works for any type of event, not just payments. Just make sure to include the event type in the table if you need to differentiate.

Related

Method: the standard formula for this calculation, rounded as each result states. See how the calculators work or report an error.