DEV Community

Cover image for Stripe Will Send It Again: 7 Deliveries to Replay Against Your .NET Webhook Before Launch
MICHAEL-MAURICE
MICHAEL-MAURICE

Posted on

Stripe Will Send It Again: 7 Deliveries to Replay Against Your .NET Webhook Before Launch

Most Stripe webhook endpoints get tested exactly once. You run stripe listen, trigger a checkout, see a 200, and move on.

That test proves the easy part. The hard part is everything Stripe promises in its docs: events arrive at least once, in no guaranteed order, and anything that doesn't get a 2xx is retried (in live mode, for up to three days). Your endpoint will meet all of that in production. It's much cheaper to meet it in a test first.

So instead of a build walkthrough, here's a replay list: seven deliveries I throw at a .NET 10 webhook endpoint before I trust it, what should happen for each, and how I check it. The code is trimmed to the shape. The trimmed parts are marked.

Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. You get complete, working, tested .NET + AI projects with full source code and PDF guides. Join Tech Skill Builder

Need payments and subscriptions in your .NET 10 app? Stripe Integration for .NET 10 is $20 on Gumroad. More about it at the end.

The setup in one picture

POST /webhooks/stripe
  verify Stripe-Signature on the raw bytes   -> bad: 400
  parse the event; type we don't handle      -> 200 "ignored"
  insert into an inbox keyed by event id     -> 200 "queued" or 200 "duplicate"
                                                (database down: 503)
background worker: claim one row with a lease -> run the handler -> done / retry later / dead
Enter fullscreen mode Exit fullscreen mode

The endpoint never runs business logic. It stores and answers. Keep that in mind, because most of the replays below are really testing that split.

Delivery 1: The plain retry

Replay: send the same event twice. Stripe signs every attempt again, so the second copy has a new timestamp and a new signature but the same event id.

Expected: both get 200. The second says duplicate, and the inbox has one row.

Watch the status code. It's tempting to answer a duplicate with 409. Don't: any non-2xx tells Stripe to try again, so you'd get retries of an event you already have.

Delivery 2: Fifty copies at the same moment

Replay: fire the same event 50 times in parallel.

Expected: exactly one queued, 49 duplicate, and one fulfillment.

This is the replay that kills "check if it exists, then insert". Two requests both check, both find nothing, both insert. The fix is to let the database decide, with a primary key on the event id and a single statement:

INSERT INTO webhook_inbox (event_id, type, payload, received_at)
VALUES ($id, $type, $payload, $now)
ON CONFLICT (event_id) DO NOTHING;
-- 1 row affected: first time. 0 rows: duplicate. (full column list in the complete project)
Enter fullscreen mode Exit fullscreen mode

When I removed the primary key and the ON CONFLICT on purpose, four tests failed, including this one. That's how I know the test can actually catch the bug.

Delivery 3: Two different events for the same payment

Replay: two events with different ids that describe the same Checkout Session.

Expected: one fulfillment.

Stripe's docs mention that it sometimes sends two separate Event objects for one change. Your event-id inbox treats them as two events, correctly. So the inbox can't be your only line of defence. The handler also deduplicates on the business key, here the Checkout Session id, with its own unique constraint. There's another reason you need this: if the worker crashes after fulfilling but before marking the row done, the lease expires and the same event runs again.

Delivery 4: "Updated" arrives after "deleted"

Replay: customer.subscription.deleted, then a late customer.subscription.updated with status: "active".

Expected: the subscription stays canceled.

Stripe doesn't guarantee order, and its docs warn against sorting by created (one-second resolution). A naive upsert brings the subscription back to life. My handler never overwrites a terminal state:

-- inside the upsert's DO UPDATE:
WHERE subscriptions.status NOT IN ('canceled', 'incomplete_expired');
Enter fullscreen mode Exit fullscreen mode

There's a second, optional defence: treat the event as a "something changed" signal and re-read the current subscription from Stripe. In my project that's an ISubscriptionSource interface, faked in the tests. I didn't wire it to the real Stripe API.

Delivery 5: A completed checkout that isn't paid

Replay: checkout.session.completed with payment_status: "unpaid", then checkout.session.async_payment_succeeded.

Expected: nothing is fulfilled on the first event; the order ships on the second.

"Completed" means the customer finished the checkout form, not that the money arrived. With delayed methods like bank debits, the payment lands later. The handler checks payment_status (paid or no_payment_required) and returns early otherwise:

if (paymentStatus is not ("paid" or "no_payment_required"))
{
    // log it and wait for checkout.session.async_payment_succeeded
    return;
}
// insert the fulfillment, unique on checkout_session_id (omitted)
Enter fullscreen mode Exit fullscreen mode

Delivery 6: A body that was touched, and a secret mid-roll

Replay A: take a validly signed event, re-format the JSON (same data, different whitespace), and send it.

Expected: 400. The signature covers the exact bytes, so verify the raw body, never something you deserialized and serialized again.

Replay B: sign with the old secret while you're rolling to a new one. Stripe keeps the old secret alive for up to 24 hours and sends one v1 per active secret.

Expected: 200. My verifier tries every v1 entry against every configured secret, which is why I wrote it myself instead of using Stripe.net's EventUtility.ConstructEvent (that takes one secret). The tests use Stripe.net as an oracle: seven headers, including stale, future, wrong-secret and "bad v1 before a good one", go through both Stripe.net's check and mine, and they agree on all seven.

foreach (var secret in options.Value.SigningSecrets)
{
    // HMAC-SHA256 over "{t}." + raw body, keyed with the UTF-8 bytes of the whole whsec_ string
    // compare to every v1 with CryptographicOperations.FixedTimeEquals (full verifier omitted)
}
// then: |now - t| must be within 300 seconds, in both directions
Enter fullscreen mode Exit fullscreen mode

Delivery 7: Your side is broken

Replay A: the inbox write throws (database down).

Expected: 503. This is the one case where you want Stripe to retry, because the next attempt can succeed.

Replay B: the event is stored fine, but the handler throws (say, the ERP times out).

Expected: Stripe already got its 200. The worker retries, with backoff (10 s, doubling, capped at an hour), and after 8 attempts parks the row as dead for a human. Stripe never hears about your ERP.

The status code table

Situation Answer Will Stripe retry?
Stored for the first time 200 queued no
Already stored 200 duplicate no
A type you don't handle 200 ignored no
Bad, missing or stale signature; not an event 400 yes (fine: fix the secret and the retries recover)
Body over your size limit 413 yes
Inbox write failed 503 yes, on purpose

The rule behind it: choose each code by asking "would a retry help?"

What I tested, and what I didn't

The demo project has 52 passing xUnit tests, covering every replay above, plus a smoke run against the real host and worker. Every signature was made locally, with Stripe.net's helper and with Python's hmac.

What I didn't verify: a real Stripe account or the Stripe CLI, Stripe's real retry timing, a real ISubscriptionSource, or several app instances on one database. The inbox is SQLite, so it's single-host. For more instances, move the same unique key and claim query to Postgres or SQL Server.

Want the full Stripe integration?

Stripe Integration for .NET 10 is $20, one-time, from Elite Solutions. It's a Clean Architecture + DDD solution (Domain, Application, Infrastructure, Api) with CQRS and MediatR, EF Core and Swagger, with Stripe.net wrapped in a StripeService. It covers customers, products and prices, Payment Intents, Checkout Sessions, refunds, subscriptions, invoices and webhooks with signature verification and deduplication by Stripe event ID, plus unit tests. To be clear: the replay demo from this post (the SQLite inbox, the worker and the 52 tests) is a separate project and is not part of the kit.

And if you learn best from complete code you can run, Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. You get complete, working, tested .NET + AI projects with full source code and step-by-step PDF guides, and new ones are added regularly.

Join Tech Skill Builder for $5/month

Which of these seven would your endpoint fail today?

Top comments (0)