Your Stripe webhook returned 200 OK.
That proves one useful but narrow fact: the endpoint acknowledged an event delivery. It does not prove that the customer received exactly one entitlement, that an order was recorded once, or that a retry cannot repeat a downstream action.
This distinction matters because Stripe recommends that webhook endpoints return a successful 2xx response quickly before complex work causes a timeout. Stripe can also retry failed deliveries, send duplicate events, and deliver events out of order. A fast 200 is part of a healthy transport layer. It is not the acceptance criterion for your business workflow.
The more useful question is:
After every permitted delivery pattern, does the system end in the one business state we intended?
Start with one explicit invariant
Do not begin with “test all Stripe webhooks.” Pick one path.
For example:
When account A completes Checkout Session
cs_test_123for product P, the system ends with one paid order and one active entitlement for account A. Replaying or concurrently processing relevant events must not create another order, another entitlement, or access for any other account.
That sentence gives you something observable. You can query your own database after each test and count the records that matter.
If your product handles refunds, cancellations, or disputes, add the expected policy rather than assuming one. A refund might revoke access immediately, at the end of a billing period, or only after a manual decision. Idempotency means reaching the same defined result repeatedly; it does not choose the correct business policy for you.
Separate three identities
Many implementations keep only event.id. That is useful, but it solves only one layer of duplication.
1. Delivery identity
The Stripe Event ID identifies a particular Event object. Stripe’s webhook guidance recommends logging processed event IDs so the same event is not processed twice.
Use a unique constraint on this value. An in-memory set is not sufficient because it disappears on restart and does not coordinate multiple workers.
2. Provider object identity
Stripe notes that separate Event objects can sometimes represent the same underlying object and event type. Its guidance suggests using the ID in data.object together with event.type when identifying that case.
For a Checkout flow, the relevant provider identity might be a Checkout Session, PaymentIntent, Invoice, or Subscription ID. Which one is authoritative depends on the path you are testing.
3. Business identity
Your application also needs its own durable identity: an order ID, account-and-product entitlement, invoice allocation, shipment, or credit.
This is the layer Stripe cannot define for you.
Two different Stripe events might legitimately point toward the same business effect. Preventing a second processing of one Event ID does not automatically prevent two different events from granting the same entitlement. A domain-level unique rule such as one order per Checkout Session, or one active entitlement per account and purchased item, provides a second boundary.
Do not confuse API idempotency keys with webhook idempotency
Stripe’s API idempotency keys protect retryable outbound API requests. For API v1, Stripe accepts idempotency keys on POST requests and returns the stored result when the same request is retried with the same key.
That is valuable when your server calls Stripe to create or update an object.
It does not automatically make your inbound webhook handler safe. Your own database writes, access grants, emails, queue jobs, and third-party calls still need durable deduplication or idempotent behavior.
Use each mechanism at its actual boundary:
- Stripe API idempotency key for a retryable request sent to Stripe;
- Stripe Event ID for duplicate delivery receipts;
- provider object plus event type when separate Event objects describe the same object transition;
- your own business key or uniqueness rule for the final side effect.
Make the receipt durable before acknowledging it
A practical handler can use a small sequence:
- Verify the webhook signature against the raw request body.
- Attempt to insert a receipt row with a unique Stripe Event ID.
- If it already exists, return success without repeating the work.
- Durably enqueue or record the business command.
- Return
2xx. - Let a worker apply the command under the relevant business uniqueness rule.
- Record the observed final state or processing result.
Stripe specifically warns that signature verification requires the raw request body; a framework that changes the body before verification can cause verification to fail. Use an official Stripe library where possible.
The exact database and queue design will vary. The important failure boundary is durability. If the process acknowledges the event before any durable receipt or command exists, a crash can produce a successful delivery with no recoverable work. If it performs the business action and crashes before recording completion, a retry can repeat the action unless that action is independently idempotent.
Run these six tests in a sandbox
Stripe provides sandboxes and test values so these checks do not move real money. Its testing guidance says not to test live mode with real payment details.
Test 1: Establish the normal-path baseline
Complete one real sandbox Checkout path. Record:
- the account and product;
- Checkout Session and relevant payment object IDs;
- the starting order and entitlement counts;
- the expected final values; and
- the actual database rows after processing.
A green success page is supporting evidence, not the final assertion. Query the stored order, entitlement, and event receipt.
Test 2: Redeliver the exact same event
Use the Dashboard’s Resend action or the documented CLI form:
stripe events resend EVENT_ID --webhook-endpoint=ENDPOINT_ID
Stripe says manual resend does not cancel any existing automatic retry, even after a 2xx. That makes this a useful real-world duplicate test.
After the resend, assert that the receipt attempts may have increased but the business result has not:
- one order;
- one entitlement;
- no repeated fulfillment transition;
- no duplicate downstream job that matters to the customer.
Test 3: Reproduce the commit-before-ack failure window
In a controlled test environment, add a temporary failure point after the database commit but before the response is returned. Let the delivery fail or time out, then resend the same event.
This tests the uncomfortable case where the business write succeeded but Stripe did not receive the acknowledgment.
The pass condition is not “the second request returned 200.” It is that the second attempt recognized the completed work and left the business state unchanged.
Test 4: Process the same command concurrently
Stripe’s Checkout fulfillment guide explicitly says a fulfillment function might be called multiple times, possibly concurrently, for the same Checkout Session.
Run two workers against the same event or fulfillment command at the same time. Application-level “check, then insert” logic can fail here because both workers can observe absence before either inserts.
The durable pass condition usually comes from a database uniqueness rule, transaction, atomic upsert, or compare-and-set transition—not timing luck.
Test 5: Use two Event objects that target one business effect
Exercise a case where different relevant Event objects could reach the same fulfillment path. Your exact combination depends on the integration; do not invent an event mapping that your application does not use.
Assert the domain invariant again. The Event IDs can both be valid and unique while the resulting order or entitlement must still exist only once.
Test 6: Reverse the assumed order
Stripe does not guarantee event delivery order and advises integrations not to depend on it. For subscriptions, for example, related subscription, invoice, and charge events might not arrive in the sequence your happy-path log showed.
Separate signature verification and transport handling from your domain processor so recorded sandbox fixtures can be applied to that processor in controlled orders. Where appropriate, retrieve the current Stripe object instead of assuming an earlier event has already arrived.
Then assert your defined final state after each sequence. Also rerun the legitimate normal path so a deduplication fix does not block valid renewals, upgrades, or later purchases.
Record evidence that answers a business question
For every run, keep a compact, sanitized record:
| Field | Example purpose |
|---|---|
| Code version and environment | Identifies exactly what was tested |
| Stripe Event ID and type | Tracks the delivery |
| Provider object ID | Connects related events |
| Internal business key | Identifies the order or entitlement |
| Starting state | Shows the controlled baseline |
| Injected condition | Duplicate, timeout, concurrency, or reordering |
| Expected final state | Defines the invariant |
| Observed final state | Database counts and status values |
| Sanitized log or test output | Supports reproduction |
| Limits | States what was not tested |
Stripe’s CLI can forward sandbox events to a local endpoint and trigger common event fixtures. For a realistic end-to-end check, however, prefer a genuine sandbox Checkout or test subscription whose objects correlate with your application records. Stripe’s Billing testing guide notes that CLI- or Dashboard-triggered subscription events can contain fake data that does not correspond to actual subscription information.
A passing webhook test is therefore not “we saw 200 in the log.” It is:
Under the tested duplicate, failure, concurrency, and ordering conditions, the same named business invariant remained true.
That conclusion is deliberately narrow. It is not a security certification, a guarantee about every payment path, or proof about an untested production environment. It is useful evidence for one revenue-critical workflow.
If you want an independent second pass on one checkout → webhook → entitlement path, the one-path Evidence Gate uses this same evidence-limited approach.
Disclosure: This article was prepared with AI assistance and checked against current Stripe documentation. It does not claim production Stripe experience or certification.
Top comments (0)