How to Handle Duplicate Stripe Webhooks Safely

A customer pays once, but your application sends two fulfillment requests. Or a payment succeeds, your webhook handler crashes halfway through, and the retry creates another credit. These are application-processing failures; a repeated notification does not itself mean Stripe charged the customer twice.
The goal is not to stop every duplicate delivery. It is to make repeated attempts converge on the intended business result. This guide describes an implementation pattern for a paid order, explains where simple deduplication breaks, and gives you concrete failure scenarios to test before launch.
A repeated notification should repeat the check, not repeat the business action.
Design the Payment Workflow to Survive Repeat Deliveries
Understand which duplicate you are handling
Stripe documents that webhook events can arrive repeatedly and out of order. Verify the signature against the unmodified request body, using the correct endpoint secret, before trusting the event. The official webhook guide explains delivery behavior and verification. Build against your configured event version rather than assuming every example payload matches your integration.
Separate three identities: the delivered event, the payment or invoice it describes, and your application's action. The event ID identifies a notification. Your order ID identifies the thing being purchased. An action such as fulfill-order should happen once for that order, even if different notifications lead your code toward it.
For a hypothetical order O-104, repeated delivery of event E-201 should not create additional work. A different event concerning the same payment must also not fulfill O-104 again. Meanwhile, the next genuinely separate order must still work. Deduplicating everything by customer ID would incorrectly block future purchases.
Give incoming events a durable inbox
Create a persistent event-inbox record with the event ID, source account or endpoint context where relevant, test/live context, event type, received time, processing status, attempt count, and last error. Store only the payload information needed for processing and investigation, with an appropriate retention policy.
Enforce the chosen delivery identity with a database unique constraint. Do not implement uniqueness as a read followed by an insert: two requests can both read “not found” before either writes. An atomic insert that conflicts on an existing key lets the database arbitrate concurrent arrivals.
“Inbox record exists” means the event was received. It does not mean the order was updated. Preserve distinct states such as pending, processing, completed, and needs attention so an interrupted attempt remains recoverable.
Acknowledge only after accepting responsibility
One practical design is to verify the request, atomically store the event, commit that write, and then return a successful HTTP response. A background worker processes pending records. If persistence fails, do not report successful receipt: let delivery fail so the sender can retry.
The worker must actually discover every accepted record. A database inbox that workers poll can provide this handoff. If you also publish to a separate queue, account for a crash between the database commit and queue publication. A transactional outbox plus a dispatcher, or an explicit recovery scan, can repair that gap.
Returning success and launching an untracked background promise is not durable acceptance. The process can stop after the response. For a duplicate already stored durably, acknowledge receipt without creating another inbox item; the existing item's processing state determines whether more work is needed.
Make the business change atomic
For our paid-order example, a worker claims a pending inbox item using a database lock or an atomic claim with a recoverable lease. It checks that the payment belongs to O-104 and meets the application's expected amount, currency, account, and successful-payment conditions. Do not fulfill an order solely because a browser reached a success page.
Within one database transaction, lock or conditionally update the order, apply the allowed unpaid-to-paid transition, insert any fulfillment job with a unique business key, and mark this event's database work complete. Commit the changes together. If any required write fails, roll back the transaction so a retry can attempt the same operation again.
The unique business key could represent “fulfill O-104 once.” It should describe a real invariant, not a universal rule to ignore all future events for the same Stripe object. Subscription changes and other repeating lifecycles need their own transitions. Scope keys carefully when multiple accounts or environments share infrastructure.
Treat external side effects as a separate problem
A database transaction cannot roll back an email already sent or a shipment already requested from another service. Write the intent to perform that external action into an outbox in the same transaction as the order update. A separate dispatcher then delivers it.
Use a stable idempotency key when the destination supports one. If a shipment request succeeds but its response is lost, query or reconcile the destination before blindly creating another shipment. Marking an action sent before delivery risks losing it; marking it sent afterward still leaves a crash window. The outbox provides durable tracking, not a magical exactly-once guarantee across services.
Stripe's request-idempotency mechanism applies to API requests you make to Stripe. It does not automatically deduplicate incoming webhooks or calls to your email provider. Match the protection to the operation you are performing.
Keep late events from undoing newer state
Arrival order is not a reliable business timeline. An older notification should not turn an already fulfilled order back into an unpaid order. Define permitted state transitions and decide when processing should retrieve current payment or subscription state from Stripe.
For example, a delayed payment-failure notification may need investigation without removing access granted by a later successful payment. An actual refund is a different business event requiring its own policy. Avoid a single “last webhook wins” update that copies whichever status arrived most recently.
Make failed work visible and recoverable
Record attempt counts, errors, timestamps, and related order IDs. Retry temporary failures with bounded backoff. Route records that repeatedly fail to an operations view or alert with enough context for someone to resolve them. A processing lease needs an expiry or recovery mechanism so a crashed worker cannot hold work forever.
Monitor the age of pending items and unresolved outbox actions, not just whether the webhook URL responds. An endpoint can return successful responses while its worker is stopped. Periodically reconcile important orders against payment state to detect omissions outside the normal delivery path.

Webhook Reliability Checklist
- Verify signatures before storing or processing trusted event data.
- Use a database-enforced unique delivery identity.
- Separate received, processing, completed, and failed states.
- Commit local business updates and fulfillment intent together.
- Make worker claims recoverable after a crash.
- Protect each external action with destination idempotency or reconciliation.
- Reject stale state transitions and inspect current state when necessary.
- Alert on old pending work and test recovery before launch.
Test the Failure Paths, Not Just a Successful Payment
Use an isolated test environment and sandbox payment objects. Record the order state, inbox records, and intended external actions before each test. The examples below are expected outcomes for this design, not a claim that an existing integration has passed them.
Repeat the same event
Resend the same event ID several times, then deliver two copies concurrently. Expect one inbox identity and one paid-order transition. A replay of an already completed event should leave the order unchanged. Creating several new test payments does not test repeated delivery of one event.
Crash before and after commit
Inject a failure before the order transaction commits. Expect its writes to roll back and the event to remain retryable. Then simulate a worker stopping after commit but before acknowledging its job. A subsequent attempt should observe completed work without inserting another fulfillment action.
Stop the worker and interrupt delivery
Leave the inbox receiver running while stopping the worker. Verify that events remain pending and monitoring detects their age. Restart the worker and confirm recovery. Separately interrupt an external-service response after that service accepts the request; verify the dispatcher uses its stable operation identity or reconciliation path.
Send different events for the same business outcome
Use separate notifications that legitimately describe the same paid order. Expect the business-level constraint to prevent a second fulfillment even though event IDs differ. Then submit a new paid order from the same customer and confirm it is processed normally.
Reorder events and reject invalid requests
Deliver an older status after a newer one, and send a request with an invalid signature. The old event must not violate the allowed state transitions. The invalid request must not create trusted work. Record a written result for each scenario before you treat the integration as ready.
FAQs
Does a duplicate webhook charge the customer again?
Not by itself. It is another notification. Duplicate charges can occur if application code responds by creating another charge or payment operation without suitable protection. Fulfillment, credits, and messages can also be duplicated independently of charging.
Is an in-memory list of event IDs enough?
It does not survive restarts or coordinate reliably across application instances. Use durable shared storage with an atomic uniqueness rule, and keep business-action protection even if older event records are eventually removed.
Should we mark an event completed before processing it?
No. Reserve or claim the work separately. Completion should reflect the committed responsibility of that worker. If external delivery is tracked in an outbox, expose its status separately instead of implying the email or shipment already happened.
Can we guarantee exactly-once delivery?
Design for repeated attempts and an effectively single business result. Every external boundary adds its own failure conditions. State precisely which actions are protected and how uncertain outcomes are reconciled.
DevConex helps teams design and review payment workflows around those failure conditions. Discuss your payment integration if your application needs reliable processing, recovery, and operational visibility.


