A reader raised a painful case on my earlier Stripe webhook article: a duplicate invoice.payment_failed delivery that sends two card-declined emails. Preventing a duplicate database update does not automatically prevent that second email. The important question is which effects share the transaction that claims the event.
My previous article describes the inbox approach. This follow-up examines its boundary, using commit 53545ae63fa289f5892e6ec57565068910601b78 of stripe-webhooks-dotnet.
First, the scope: this sample does not implement failed-invoice emails. Its payment handler supports successful payment intents and refunded charges. The reader's scenario is an extension to consider, not a feature demonstrated by this repository.
The inbox and the worker solve different races
The repository overview describes insert-first deduplication at receipt: a unique event identifier prevents repeated deliveries from creating multiple inbox records. The request stores the event before acknowledging it and offers its identifier to a bounded queue. A sweeper recovers pending work.
That still leaves the worker boundary. A pending identifier can reach processing more than once, so receipt deduplication alone is insufficient.
In EventProcessor.cs, processing begins a transaction:
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
Inside it, a conditional update changes the matching inbox row from Pending to Processed. The returned affected-row count determines whether this invocation obtained the claim. An invocation that changed no row stops here:
if (claimed == 0)
{
logger.AlreadyHandled(eventId);
return;
}
Marking the row Processed happens before the handler, but inside the transaction. It is not a committed declaration that the handler has already succeeded.
The local changes commit with the claim
After claiming the row, the processor parses the stored payload and invokes the handler. These lines complete the successful path:
row.Outcome = await handler.HandleAsync(stripeEvent, cancellationToken);
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
The implemented handler changes payment entities through its database context. If handling throws before commit, transaction disposal rolls back the claim and those local changes. The processor then clears tracked state and records failure information for retry.
That explains the guarantee's useful boundary: the claim and the payment update land together. It does not mean every possible action added inside HandleAsync participates in that transaction.
Sending an email introduces another system
Suppose we extend the handler to send a card-declined email before committing. The email provider accepts it, then the database commit fails. The inbox claim rolls back, leaving the event eligible for retry. The email provider has no reason to undo its accepted message. A retry can send another email.
Moving the send after commit creates another gap. The process might stop after committing the payment change but before sending. On retry, the committed inbox claim tells the processor to skip the event. The notification can be lost.
These are hypothetical failure sequences for an extension. The repository contains no email sender that I exercised, and its transaction cannot demonstrate email-delivery guarantees.
An outbox would persist the intent
For that extension, I would save a notification intent in an outbox table within the same transaction as the inbox claim and business update. A separate dispatcher would read committed intents and attempt delivery. This is a proposed design; an outbox and its dispatcher are not implemented in this sample.
An outbox addresses the gap between saving the business change and remembering that notification work exists. It does not by itself guarantee one external email. A dispatcher can stop after the provider accepts a message but before recording success locally.
Where the provider supports suitable idempotency, the dispatcher should reuse a stable notification key across retries. Otherwise, the design needs an explicit policy for uncertain outcomes and possible duplicates. I would also define whether two different event IDs should create one notification for the same business occurrence. Event-ID deduplication alone does not answer that product question.
What the tests establish
The duplicate-delivery test posts the same payload again after the first delivery is handled. Its visible assertions check accepted and duplicate acknowledgments and one stored event row.
The README also documents concurrency tests: ten rounds of twenty-five parallel deliveries, repeated worker processing, and concurrent claims of one pending event. It describes handler-call counting and injected failures to distinguish repeated application from an unchanged final state. Those are local-processing checks, not email tests.
For this article, I fetched public files with curl at the fixed commit and inspected the processor, payment handler, visible idempotency-test excerpts, and README. I ran no build, dotnet test, Stripe CLI session, email-provider request, or crash experiment. SQLite serializes writers; the documented tests do not establish another database server's locking behavior.
The extension needs its own evidence: durable notification creation, dispatcher recovery, and behavior when provider acceptance has an uncertain result. Those checks would support claims about notifications that the existing payment tests cannot establish.
If you need Stripe webhooks handled this way in your ASP.NET Core app, I offer it as a fixed-price project on Upwork: https://www.upwork.com/services/product/2114777474333655338
Top comments (2)
This boundary is the right place to look. One wrinkle I'd add: deduping on the event id only covers redelivery of the same event. The same decline can also surface as distinct events - charge.failed alongside invoice.payment_failed - each with its own id, and each dunning retry mints a fresh event id. So if the email should mean "this invoice's payment failed", the idempotency key wants to be the user-visible fact (invoice id + payment attempt), not the event id. Otherwise a perfectly claimed inbox still sends two decline emails for one decline, just from two different events. The claim transaction then guards a fact-keyed effect row instead of an event-keyed one.
Good point. It moves the key to where the email's meaning lives. Two things I'd add after checking Stripe's docs.
First, I'd pick one event type as the trigger for the decline email, typically
invoice.payment_failed, and not send that email fromcharge.failedorpayment_intent.payment_failedat all. That removes the cross-event duplicate before any key is involved.Second, on the attempt part: the Invoice object reference describes
attempt_countas counted "from the perspective of the payment retry schedule". The first attempt counts, then only automatic retries increment it; manual attempts after the first don't. So(invoice id, attempt_count)gives one email per step of the retry schedule, but two failed manual attempts in a row would share a key and produce a single email. That may be exactly what you want, but it's worth deciding on purpose.Then, as you say, the claim transaction inserts a row keyed by that fact, for example
decline_notice(invoice_id, attempt_count)with a unique constraint, and the email dispatcher works from that row. Same caveat as in the article: the sample repo doesn't send failed-invoice emails, so this is a design note, not something it demonstrates.