A repeatable Stripe webhook replay test is a small fixture pack plus assertions about the final billing state. Run the same verified event twice, run a newer event before an older one, and force one recoverable failure. If each run ends with one intentional entitlement state and an auditable receipt, your handler has a much more useful proof than a successful Checkout screenshot.
This guide focuses on that test boundary. It assumes you already verify Stripe signatures against the raw request body and have decided where subscription and access state live. Stripe documents that webhook endpoints can receive duplicate events and that delivery order is not guaranteed, so a happy-path test alone cannot establish that paid access remains correct after transport retries or delays.
What this test should prove
The goal is not to simulate all of Stripe. It is to prove that your application reacts safely to a repeated event, a delayed older event, and a temporary failure before the write finishes. Those cases cover the gap between a request reaching a route and a customer seeing the correct access.
Keep the test at the server boundary. In Frontend Accelerator's current product repository, the payment route receives the raw request, asks a payment adapter to verify the webhook signature, maps the provider event, and then applies payment-related changes through a database adapter. That separation is useful because the replay suite can exercise normalized inputs and the state service without making browser redirects or UI components the authority for billing.
Recommendation: treat the final state and the durable receipt as the assertion. A 200 response by itself does not prove that the intended subscription or entitlement transition happened exactly once.
Define a narrow fixture pack
Start with fixtures your integration actually subscribes to. For a Checkout-led subscription flow, a compact first pack can contain one completed checkout, one subscription update, one cancellation, and one deliberately malformed or unmapped event. Record the event ID, the Stripe resource ID, the provider timestamp, the normalized status, and the expected entitlement result. Do not copy production customer data into fixtures.
Fixture manifest
- checkout-completed.json: a verified event for a known test customer and price mapping. Expected result: one durable receipt and the initial access state your policy defines.
- subscription-active-v2.json: the same subscription at a later provider timestamp. Expected result: the projection advances to the policy for active status.
- subscription-canceled-v1.json: an older cancellation or update for that subscription. Expected result: it is retained as a received event but does not overwrite a newer projection.
- unknown-price.json: a verified event with no internal entitlement mapping. Expected result: no access change and a reviewable configuration outcome.
- bad-signature.json: a body or header pair that cannot verify. Expected result: a rejection before any receipt or entitlement write.
The names are less important than the contract. Every fixture must be deterministic, minimal, and safe to replay locally. Store the provider timestamp in the fixture rather than using the test clock, otherwise a stale-versus-fresh assertion can silently become a comparison of when the test happened to run.
Write expected state before the harness
Make the expected outcome explicit for each scenario. A short acceptance list is easier to keep beside the fixtures and avoids a vague test that only checks status codes.
- Receipt count: the same Stripe event ID can be claimed once. A second delivery must be classified as already processed or another intentional duplicate outcome.
- Projection freshness: an older provider update cannot replace a newer normalized subscription state just because it arrived later.
- Entitlement count: the customer receives one active entitlement for the mapped product, not an extra record or repeated side effect.
- Failure visibility: an unknown price mapping, account conflict, or transient store failure has a distinct outcome that an operator can inspect.
- Response policy: return 2xx only when the event is durably accounted for or deliberately ignored. A retryable incomplete transition should surface a server failure so Stripe can deliver again.
Stripe recommends logging processed event IDs to protect against duplicate receipts. It also explains that two distinct Event objects can sometimes represent the same logical change, which is why a receipt keyed only by event ID does not replace a resource-level freshness policy. Your test should cover both layers: identical delivery and a logically conflicting delivery for the same subscription.
Build a replay harness around your own boundary
The harness should call the same server-side handler or domain service used in production, with a controlled database. Keep provider SDK calls out of the assertion path where possible; they make fixture tests slow and can conceal a state bug behind a network response. If the handler must retrieve Stripe data, make that retrieval an injected dependency and stub its verified response.
const first = await replay(fixture("checkout-completed.json"));
const duplicate = await replay(fixture("checkout-completed.json"));
expect(first.outcome).toBe("applied");
expect(duplicate.outcome).toBe("duplicate");
expect(await receipts.countByEventId(first.eventId)).toBe(1);
expect(await entitlements.forUser(testUserId)).toEqual([
{ key: "pro", isActive: true }
]);
This is illustrative TypeScript, not a repository excerpt. Adapt the names to your payment and database interfaces. The important sequence is that the second invocation uses the identical event identity and that the assertion queries the final durable state rather than a mock call count alone.
Run three replay cases
1. Deliver the same event twice
Replay the completed Checkout fixture twice without resetting the database. Assert that the first call creates or claims a receipt and applies the intended transition. Assert that the second call produces no additional entitlement mutation, email, analytics record, or background job. If a follow-on side effect is required, write an outbox item in the same durable transaction and test its unique key too.
2. Deliver a newer state, then an older one
Apply the later subscription fixture first. Then replay the earlier cancellation or update fixture. The final projection should remain at the later provider timestamp, and the event journal should explain that the older event was received but not applied. Do not use local receipt time as the ordering signal: Stripe says events are not guaranteed to arrive in the order they were generated. Use the verified provider fields your subscription policy chooses, and document tie-breaking behaviour.
3. Fail once before the state commit
Make the repository throw a transient error before its transaction completes. Assert that no partial entitlement survives, that the receipt is left retryable rather than falsely complete, and that a later replay succeeds. This catches the dangerous path where code returns success after an exception or marks an event processed before the access projection exists.
If you use Firestore, run this set against the Emulator in addition to unit doubles. Transaction callbacks may retry, and emulator tests expose document-path, serialization, and atomicity mistakes that mocks often hide. Keep callbacks limited to reads and writes; do not send email or make external calls from a retried transaction callback.
Use Stripe test tools for a second layer
Fixture tests give you fast, deterministic state coverage. Add a separate sandbox check to confirm the endpoint's signature handling and delivery wiring. Stripe documents using the Stripe CLI to forward events to a local endpoint, and it supports triggering test events. Stripe also notes that CLI or Dashboard-triggered events can contain synthetic data that does not correlate to a subscription, so use a real sandbox subscription lifecycle when you need to confirm price mappings, customer links, and subscription behaviour together.
stripe listen --forward-to localhost:3000/api/webhooks/payment
stripe trigger checkout.session.completed
Use the signing secret shown by your local listener only in your local environment. Never place it in a fixture, test output, commit, or article example. For a production-like confirmation, create an actual sandbox subscription, capture the event IDs in your test log, and compare the resulting normalized projection with the acceptance rules above.
Keep the test specific to your access policy
A replay test cannot decide whether past_due should keep access active, whether a refund revokes a one-time product, or how to resolve a customer ownership conflict. Those are product policies. Write them down beside the fixtures, use a narrow price-to-entitlement mapping, and make unsupported states visible for manual reconciliation.
That division is especially helpful when coding agents contribute to billing work. A structured foundation gives the agent a payment adapter and a server-side boundary to extend, while the fixture pack gives you an acceptance gate the agent cannot satisfy with a plausible-looking route alone. You still review the policy, run the test suite, and decide what ships.
Replay-test checklist
- Verify the raw body and provider signature before parsing or writing state.
- Keep provider event ID, resource ID, provider timestamp, normalized status, and outcome in a durable receipt.
- Replay one exact event twice without clearing the database.
- Replay an older event after a newer state for the same resource.
- Force a retryable store failure and prove a later replay is safe.
- Run deterministic fixtures in CI and one sandbox delivery check before production changes.
- Keep secrets, real customer data, and raw payment payloads out of test artifacts and logs.
Once these cases pass, you have a repeatable proof that your webhook path is designed for the delivery conditions Stripe documents. The next sensible improvement is observability: retain minimal, redacted outcomes so an administrator can investigate a customer whose provider state and application access ever disagree.
Frontend Accelerator provides a structured Next.js SaaS foundation with payment-provider adapters and server-side boundaries you can test and extend. Explore the feature set.



