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
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)
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');
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)
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
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)