A payment webhook can arrive twice even when nothing is wrong with my application. The uncomfortable case is when the second delivery arrives while the first one is still running. If both requests check whether an event exists before either inserts it, both can decide to apply the payment.
I built stripe-webhooks-dotnet to make those cases reproducible. The sample receives signed Stripe events, stores them in a durable inbox and processes local payment changes in a background worker. Its suite has 38 tests, passing in CI on .NET 8 and .NET 10. Here is the pattern, including the boundaries I would keep visible before adapting it to another API.
A retry is another delivery of the same work
An HTTP response can disappear after my server has committed a change. From the sender's perspective, that request failed. Retrying is reasonable, but running the business operation again may be expensive.
Returning a successful response quickly helps, but it does not make the operation idempotent. Neither does an in-memory collection of event IDs: it disappears on restart and cannot coordinate separate application instances.
I use the Stripe event ID as the inbox deduplication key. Two deliveries of that ID compete for one database row. This addresses repeated delivery of an event; business rules may also need protection against different events describing the same underlying operation.
Verify the original body first
Before trusting the event ID or storing the payload, I verify the signature. Stripe signs the original request body. Deserializing JSON and serializing it again can change whitespace or property ordering, so I keep the received body for verification.
This excerpt comes directly from WebhookReceiver.cs:
stripeEvent = EventUtility.ConstructEvent(
json,
signature,
settings.WebhookSecret,
tolerance: settings.SignatureToleranceSeconds,
utcNow: clock.GetUtcNow().ToUnixTimeSeconds(),
throwOnApiVersionMismatch: false);
The receiver limits the body before verification, with a default cap of 1 MB. Signature verification uses a configured tolerance, defaulting to 300 seconds, and an injected clock. Missing or invalid signatures receive a 400 response.
I check API-version compatibility separately after signature verification. A correctly signed event from an incompatible API release should produce a useful configuration error instead of being mislabeled as a bad signature. Disabling the SDK's combined version exception here does not mean accepting incompatible events.
Let the insert decide who wins
The next step is an insert, without a preliminary existence query. ProcessedEvents has a unique index on EventId. The receiver uses this statement:
var inserted = await db.Database.ExecuteSqlInterpolatedAsync(
$"""
INSERT INTO ProcessedEvents (EventId, Type, EventCreated, Payload, Status, Attempts, ReceivedAtUtc, NextAttemptAtUtc)
VALUES ({stripeEvent.Id}, {stripeEvent.Type}, {created}, {json}, {nameof(EventStatus.Pending)}, 0, {receivedAt}, {receivedAt})
ON CONFLICT (EventId) DO NOTHING
""",
cancellationToken);
The interpolation API supplies database parameters. The SQL conflict clause is SQLite-specific; I would adapt it deliberately when changing database providers.
One insert wins. A duplicate affects zero rows and receives a 200 response with a duplicate acknowledgement. The winner stores the verified payload as Pending before acknowledging acceptance.
That ordering is essential. If I acknowledge first and then crash before storage, Stripe has a successful response while my application has lost the work. Here, acceptance means durable receipt; it does not mean the payment handler has finished.
Make the queue disposable
After storage, the receiver attempts to enqueue the event ID into a bounded channel. The channel is a scheduling aid. The database remains the durable record.
If the channel is full, the receiver still acknowledges the stored event. A sweeper finds due Pending rows on startup and periodically afterward, then queues their IDs again. Losing the channel during a restart should delay processing rather than lose accepted events.
This also means the worker must tolerate receiving the same ID repeatedly. Deduplicating HTTP requests alone would leave a second duplication point inside the application.
Commit the claim and payment together
The processor opens a database transaction before claiming an event. Its conditional update is another excerpt from the repository:
var claimed = await db.ProcessedEvents
.Where(e => e.EventId == eventId && e.Status == EventStatus.Pending)
.ExecuteUpdateAsync(
set => set
.SetProperty(e => e.Status, EventStatus.Processed)
.SetProperty(e => e.ProcessedAtUtc, clock.GetUtcNow().UtcDateTime),
cancellationToken);
Zero affected rows means another execution has already moved the event out of Pending. Otherwise, the processor loads the stored payload, invokes the payment handler, saves the changes and commits.
Although the update sets Processed before the handler runs, that claim remains inside the transaction. If the handler throws, rollback undoes both the claim and the local payment change. A successful commit makes them durable together.
After failure, the processor records an attempt and schedules a retry with backoff. The defaults allow ten attempts. An exhausted event becomes Failed and needs operational attention: Stripe already received its acknowledgement, so recovery now belongs to my application.
What the tests actually exercise
The suite has 38 tests. The project targets .NET 10 and .NET 8, and CI runs the suite on both runtimes.
The tests host the application through WebApplicationFactory with SQLite files and generated webhook secrets. An independent signing helper constructs headers, while a handler wrapper counts calls and injects failures. That gives stronger evidence than checking only the final payment row.
Coverage includes invalid signatures, duplicate delivery, competing worker claims, rollback, retry exhaustion, recovery of stored Pending events, queue backpressure and stale payment snapshots. The concurrent-delivery case performs ten rounds of 25 parallel requests: 250 requests within that test, rather than 250 separate tests.
Where I draw the boundary
The once-only guarantee concerns local database effects committed with the inbox claim. Sending email, shipping an order or calling another API introduces another failure boundary. I would add an outbox and downstream idempotency before extending that guarantee to external actions.
SQLite serializes writers. These concurrency tests do not establish SQL Server or PostgreSQL locking behavior, distributed throughput or production capacity. I would validate the chosen provider under representative contention.
Deduplication also does not solve event ordering. The sample compares event timestamps and includes refund-specific tie handling; those rules need review when adding event types. Failed-event monitoring, replay procedures and payload retention remain operational responsibilities.
The three excerpts above are from WebhookReceiver.cs and EventProcessor.cs at revision 53545ae. I keep that revision explicit so readers can compare the explanation with the implementation.
If you need idempotent Stripe webhooks in your ASP.NET Core API, write to me about your payment flow and the failure cases you need to handle.
Top comments (0)