Stripe Webhook Idempotency: Why Your Handler Needs It and How to Build It

SendPromptly
6 min read

Stripe guarantees at-least-once delivery for webhook events. That means the same event can arrive at your endpoint more than once — after a network timeout, after your server returned a non-2xx response, or during a retry window after an outage.

If your webhook handler is not idempotent, duplicate deliveries cause duplicate effects: credits granted twice, access provisioned twice, downstream actions triggered twice, emails sent twice.

This guide explains what idempotency means for webhook handlers, why it matters for more than just duplicate protection, and how to implement it correctly.

What Idempotency Means for a Webhook Handler

An idempotent operation produces the same outcome whether it runs once or ten times. For a Stripe webhook handler, this means:

  • Processing checkout.session.completed for event evt_aaa once grants the customer access
  • Processing the same event a second time does not grant access again — it recognizes the event has already been handled and exits cleanly
  • Processing it a tenth time produces the same result as the second

The handler is not required to do nothing on duplicate deliveries. It is required to not produce different effects.

Why Idempotency Matters Beyond Duplicate Protection

Teams often think of idempotency as a safety net for rare duplicate deliveries. In practice, it enables a workflow that is far more valuable: safe replay.

When a webhook was missed — due to a worker outage, a deployment, a bug — the recovery path is to replay the event. Replay is clean, fast, and uses your existing code. But replay is only safe if your handler is idempotent.

Without idempotency, replay is dangerous. You cannot be sure whether the original event was partially or fully processed. Replaying risks double-provisioning. So instead, teams resort to manual database edits — slower, riskier, and without an audit trail.

Idempotency is what turns replay from a risky workaround into a standard recovery tool.

How to Implement Idempotency: The Event ID Pattern

The standard approach is to track processed event IDs in a database table and check that table before applying any business effect.

Database table

1
2
3
4
5
6
CREATE TABLE processed_stripe_events (
  id          SERIAL PRIMARY KEY,
  event_id    VARCHAR(255) NOT NULL UNIQUE,
  event_type  VARCHAR(255) NOT NULL,
  processed_at TIMESTAMP NOT NULL DEFAULT NOW()
);

The UNIQUE constraint on event_id is the critical piece. It prevents concurrent duplicate handlers from both inserting the same event ID — the second insert will fail with a constraint violation, which you catch and treat as “already processed.”

Handler logic

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
def handle_checkout_session_completed(event):
    try:
        ProcessedStripeEvent.create(
            event_id=event.id,
            event_type=event.type
        )
    except UniqueConstraintViolation:
        # Already processed — exit cleanly
        return

    # Safe to apply business effect here
    grant_access(
        user_id=event.data.object.metadata.user_id,
        plan=event.data.object.metadata.plan
    )

The insert-first pattern is important: you attempt to claim the event before applying the effect. If two concurrent handlers race, only one will succeed at the insert — the other will catch the constraint violation and exit. This prevents the race condition where both handlers check “has this been processed?” simultaneously, both see “no,” and both proceed.

When the insert succeeds but the effect fails

If the event ID is inserted but the business effect raises an exception, you have a problem: the idempotency record exists, but the effect was not applied. Future attempts (retries, replays) will see the record and skip processing.

Fix: Wrap the insert and the business effect in a database transaction.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def handle_checkout_session_completed(event):
    with db.transaction():
        try:
            ProcessedStripeEvent.create(
                event_id=event.id,
                event_type=event.type
            )
        except UniqueConstraintViolation:
            return  # Already handled

        # If this raises, the transaction rolls back,
        # including the event ID insert
        grant_access(
            user_id=event.data.object.metadata.user_id,
            plan=event.data.object.metadata.plan
        )

With this pattern, either both the record and the effect are committed together, or neither is. If the effect fails, the record is rolled back and future attempts can retry safely.

Idempotency at the Business Effect Layer

For some operations, you also need idempotency at the business logic level — not just at the event tracking level.

Credit grants: When adding credits, check whether credits from this specific invoice have already been added:

1
SELECT id FROM credit_grants WHERE invoice_id = 'in_xxx'

This handles the case where your event ID tracking was not in place before an incident, and you need to safely replay events that may have been partially processed.

Access provisioning: Use upsert rather than insert. If the user already has the plan, an upsert to the same plan state produces no visible change. An insert would fail with a uniqueness error or create a duplicate record.

Email sending: Check whether the email for this event has already been sent before sending. A boolean flag on the user or a record in a sent-emails table serves this purpose.

Idempotency for Background Jobs

If your webhook handler enqueues a job for asynchronous processing, idempotency must be implemented in the job, not just the handler. The handler returns 200 before the job runs — two deliveries of the same event mean two jobs enqueued, both of which will execute.

Your job should either:

  • Carry the Stripe event ID as a uniqueness key and perform the same insert-first deduplication check
  • Use your queue system’s built-in deduplication (Sidekiq Unique Jobs, BullMQ unique jobs, etc.)

Queue-level deduplication prevents the second job from being enqueued at all. Application-level deduplication in the job prevents duplicate effects if two jobs do make it to execution.

What Good Idempotency Looks Like End-to-End

LayerMechanism
Webhook handlerInsert event ID first; catch unique constraint to detect duplicate
Database transactionWrap insert + business effect; rollback both on failure
Business effectUpsert or pre-check for existing state
Background jobJob-level deduplication on event ID
Downstream actionsEmail/notification checks before sending

Idempotency at every layer means that a replay — whether triggered by a retry, an operator, or an automated recovery system — will safely produce the correct final state without any duplicate effects.


Idempotent webhook handlers are the foundation of safe payment recovery. SendPromptly is built around the assumption that your handler is idempotent — it issues repair callbacks that your endpoint can receive and apply safely, using the same idempotency guarantees. Learn about the repair workflow →