What has to commit together when a payment arrives twice
In a reference shop with hedged requests and retried webhooks: what must land in one write, and why durable intent, rediscovery and receiver idempotency are separate.

A checkout request in my reference shop could be running at the same time as a copy of itself. The platform’s gateway hedged requests up to three times without looking at the method, so a POST gets the same treatment as a GET. At the other end of the flow, Stripe retries webhooks for days. Neither of those is a fault to be handled. They are how requests reach the application, so the design question is which state has to commit together for a duplicate to be recognized and given the same answer as the original.
The shop was a reference application for a framework I built, and its answer was a single document. Stock levels, orders, the index of orders by owner, the idempotency map, metrics, the audit trail and the email ledger all lived under one key in a versioned store, and every change went through one loop: read the document and its version, apply a closure, write only if nothing has moved since the read, and start again if something has. Two writers can read the same version, but only one can replace it successfully.
The reason is what the platform did not offer. That store had no transaction across keys, and none with the separate atomic plane next to it, so if stock and orders had lived under different keys, there would have been a moment when the stock was taken and no order existed, and a crash at that moment leaves a fact that no retry can interpret. With one document that moment is gone. The reservation, the order, the owner index and the record that this idempotency key has been used are one write, and they all land or none do. The general rule is that whatever a retry will read to reach its decision has to commit with whatever that decision changes. The price is the obvious one: every writer in the shop contends for a single key and rewrites the whole document, a ceiling on throughput I accepted for a reference application and would not carry into a busy store.
Inside the loop, placing an order is a short sequence. If the idempotency map already has this key, return the order it points to. Otherwise check stock and refuse if it is short, reserve, insert the order, record the key, and update the owner index, the metrics and the audit trail. Validation and pricing happen once, before the loop. The idempotency check happens on every attempt, because the writer that made my compare-and-swap fail may have been my own hedged twin, and only a fresh read can tell me that.
That map can look redundant, because the framework’s action layer keeps an idempotency marker of its own. Each invocation gets a digest of its action name and key, length-prefixed so that ("a:b", "c") and ("a", "b:c") cannot collide and leave one invocation silently answering with another’s result, and the shop checks for a recorded result before it runs anything. But that marker lives in the atomic plane and the document lives in the store, and the two commit independently. A crash between them leaves the order written and the marker missing. The retry passes the outer check and runs the core again, and if the outer check were all there was, it would take the stock twice. Reading the marker earlier would not help, because the gap is between the planes and not before them. The question “was that me?” can only be answered by something that committed with the write, which is why the framework’s general mechanism puts the writer’s action id inside the stored value, and why the shop keeps its map inside the document.
A retry also has to get the same reply, including any secret in it. A guest order comes with a token for managing it, which a retry must return unchanged and the document should not hold. So the token was derived from the idempotency key, and only its MAC was stored.
The webhook
The paid webhook asks the same question after a longer delay. Its signature comes first. The raw bytes were verified before they were parsed, against every v1= candidate in the header, in constant time, with a five-minute tolerance, and the verifier offers no entry point that accepts a parsed value. A missing signature was refused like a forged one. Then the event has to be admitted and applied, and there are two ways to do that in two steps. Mark the event first, and a crash loses the payment, because Stripe’s next attempt finds the event already marked. Apply it first, and a crash applies it twice. Admission and the state change went in the same write, and the check for whether the event had been admitted sat at the top of every attempt, because a conditional write over a network can land and still look like a failure to the caller.
The event then has to match the order it claims to pay for. A wrong session, a short amount or the wrong currency was refused and left the order open, an event with no total was treated as unverifiable and not as a pass, and the only things that could mark an order paid were the signed webhook and a verified reconciliation request to Stripe. A scripted end-to-end test walks through these: the three bad events, with the order still open afterward, then the exact event, applied once, then its second delivery, flagged as a duplicate and counted in the metrics.
What cannot go in the write
So far everything has fit in one write, and a confirmation email cannot. What can go in is the intent. The write that marked an order paid also created a pending entry in the email ledger, with a key derived from the order id. After the commit, the application advanced its cache tags and spawned the send. The send made at most one provider call, and if it then failed to record the result it retried the write and not the call, up to eight attempts, adopting another writer’s sent if one had gotten there first. In the end-to-end test, with no provider configured, the entry settled as not configured, a retry raised its attempts to two under the same key, and the order stayed paid throughout.
That looks like an outbox, but an outbox makes promises this ledger does not, and the difference is worth pulling apart. There are three separate properties: the intent is durable, something will find it and try again, and the receiver can recognize a repeat.
The framework does have a real outbox for actions. Its intents commit in the same envelope as the action marker, and the same commit updates a pending index in one of 16 fixed shards, because the atomic plane has no prefix scan and the drain has to know where to look. Publishing a wake-up message after the commit is a second plane, and a crash in between leaves an intent that nothing ever finds, admitted and unperformed. A wake-up may still be published to cut latency, since losing it then costs a delay and cannot cost the effect. The drain performs the effect and then records completion, so a crash in between repeats it. That is at-least-once, deliberately, because recording first could mark complete something that never happened.
The shop did not use that outbox. Its send was a spawned task, nothing re-enqueued a spawned send, and there was no sweeper. If the process died between the commit and the send, the entry stayed pending, on the record, until someone retried it by hand.
| Shop email ledger | Framework outbox | |
|---|---|---|
| Intent is durable | In the same write as the paid state | In the same envelope as the action marker |
| Something finds it and retries | No: pending until a manual retry | Yes: the drain reads a pending index |
| Receiver can recognize a repeat | The same key goes to the provider | The idempotency key goes with the intent |
The last row of the table depends on the receiver, and no commit on my side can deliver it. If the provider accepts the message and the process dies before recording that, the ledger says pending about something already sent, and a manual retry makes a second call under the same key. Whether that becomes a second email is up to Resend, which documents a 24-hour window for its idempotency keys.
The shared kit follows the same boundary. Its webhook verifier and event ledger issue no I/O, hold no secrets and own no transaction. They can verify a signature and say whether an event has been seen, but the application’s guarded commit has to carry that answer together with the order, because only the application holds the order.
Chris Chabot, November 2026