Ground Truth

Accept Is Binding, So Accept Has To Be Idempotent

Mostafa DhouibMostafa Dhouib·10 min read
The short answer

When a counterparty can accept an executable quote from a phone, the accept action will be repeated. A tap on poor signal that appears to do nothing gets tapped again, clients retry on timeout, and proxies replay. Unless an idempotency key is checked before the state transition is applied, the second attempt creates a second binding trade against someone who believes they agreed once.

Accept Is Binding, So Accept Has To Be Idempotent

The short answer. Idempotency on the accept action is a product requirement, not an engineering refinement you add later. When a counterparty can accept an executable quote on a phone, a tap made on poor signal appears to do nothing, so it gets tapped again. Clients retry on timeout. Proxies and gateways replay. Without an idempotency key checked before the transition is applied, that second attempt is a second binding trade, and the operator holds twice the exposure they intended against a counterparty who believes they agreed once. The key has to be minted where the intent is formed, which is the quote, not the request.

A trading platform issues a price that a supplier can accept without anyone reviewing it first. The accept button creates an obligation on both sides the moment it lands.

That changes what the button is. It is no longer a form submission that can be safely retried because the worst case is a duplicate draft. It is the point at which the platform commits capital, and the network it runs over gives no guarantee at all about how many times a request will be delivered.

The tap that appears to do nothing gets tapped again

Picture the actual situation rather than the sequence diagram.

Someone is standing in a yard, on a phone, with one or two bars of signal. They tap accept. The spinner turns. Nothing comes back, because the response was lost on the way home, or the connection dropped after the server had already written the row, or the radio went to sleep and the socket died.

From where they are standing, nothing happened. The quote is still on screen. So they tap it again.

This is not user error and it is not an edge case. It is the correct behaviour for a person who has been given no evidence that their action landed. Any product that asks for a binding decision over a mobile connection will receive that decision more than once, and the number of times it receives it has nothing to do with how many times the person decided.

The failure is specific. Both requests arrive. Both pass validation, because the quote was live when each was sent. Both apply the transition. Now there are two accepted trades at the same price for the same volume. The operator's position is double what they planned. Settlement will eventually produce two obligations. The counterparty will dispute the second one and they will be right.

  1. Accept tapped in a yard
    one or two bars of signal, and a quote that binds the moment it lands
  2. The server writes the row
    the transition is applied and the obligation exists
  3. The response is lost
    or the connection drops, or the radio sleeps and the socket dies
  4. The screen still shows the quote
    from where the person is standing, nothing happened
  5. Accept tapped again
    and this time it arrives alongside a client retry and whatever the proxies replayed
  6. Two binding trades
    same price, same volume, double the position, and a second obligation the counterparty will dispute and be right about
The number of times the platform receives the decision has nothing to do with how many times the person decided.
FigureThis is not user error and it is not an edge case. It is the correct behaviour of a person who has been given no evidence that their action landed. Both requests validate, because the quote was live when each was sent, and both apply the transition, so the operator holds twice the exposure they intended against a counterparty who decided once.

Idempotency is a product requirement, not a technical nicety

The reason this is worth stating plainly is that idempotency usually gets filed as an infrastructure concern. It sounds like a retry policy, or something the transport layer ought to handle, or a hardening task for after launch.

For a transactional product it is none of those things. It is part of the definition of the feature. "The counterparty can accept this quote" means, in full: the counterparty can express agreement once, and no amount of network behaviour, client behaviour or infrastructure behaviour can turn that single agreement into two obligations.

Written that way, it belongs in the acceptance criteria next to the price and the expiry, because it is the same kind of statement. A platform that gets the price right and the duplicate wrong has not got the feature mostly working. It has a feature that occasionally invents trades.

It also changes who has to care. If idempotency is a technical detail, it can be traded away under schedule pressure by someone who does not know what it protects. If it is part of what accept means, removing it is visibly removing part of the product.

Where the key comes from

The key has to identify the decision, not the request. That one sentence decides most of the design.

Mint it with the quote, not with the call. The server issues the executable quote and includes an acceptance token in it. That token is the identity of this particular opportunity to agree. Every accept attempt for that quote carries that token, whether it is the first tap, a retry the client made on its own, or a replay from a proxy. All of them resolve to one key, because all of them are the same decision.

Do not let the client generate it fresh per attempt. A client-generated identifier created at the moment of the tap is a different value on the second tap, which means the two taps look like two decisions and you are back where you started. Client-side generation only works if the value is created once when the screen is rendered and then held across every retry, and in practice that is harder to guarantee than putting it in the quote where the server controls it.

Do not derive it from the payload. Hashing the request body feels tidy and fails in both directions. Two genuinely separate decisions with identical fields collapse into one, which silently drops a real trade. And a payload that differs only in a timestamp or a client version produces two keys for one decision.

Make it single-use and scoped to the quote. The token should be valid for that quote, for that counterparty, until the quote expires or the transition completes. That scoping is what stops a captured token being replayed against something else, and it means the key's lifetime is the same as the decision's lifetime.

The client behaviours that force it

Three separate mechanisms produce repeats, and they need naming separately because mitigating one does not mitigate the others.

The user double-taps. Covered above. Disabling the button after the first tap helps in the one case where the app stays alive and responsive, and does nothing when the app is backgrounded, the screen is reloaded, or the person force-quits and reopens to try again.

The client retries on timeout. Most networking stacks retry by default, and most mobile clients add their own retry on top, because without it the product feels broken on a bad connection. A timeout tells the client nothing about whether the server acted. The only safe retry is a retry of something idempotent, which means the retry policy and the key are the same design decision.

Something in the middle replays. Load balancers, gateways, service meshes and mobile acceleration layers all retry. So does any queue or webhook relay in the path, because at-least-once delivery is what those systems honestly offer. A request can therefore arrive twice with the client having sent it once.

That third mechanism is why client-side guards are not sufficient. The duplicate can be generated after the client is finished and no longer has any say in it.

Check the key before the transition, not around it

The ordering is where otherwise sound designs still go wrong.

The check has to happen in the same atomic unit as the transition, against durable storage, before any effect is applied. In a relational store the natural tool is a uniqueness constraint on the key, inserted in the same transaction that writes the acceptance. The database then resolves the race rather than your code. If the insert conflicts, this attempt is a repeat and the transition does not run again.

The patterns that look right and are not:

Reading first, then writing. Check whether the key exists, see that it does not, then insert. Two concurrent requests both read nothing and both proceed. Double taps on a flaky connection arrive close together, which is exactly when this race is most likely.

An in-memory cache of keys already seen. Fine until a restart, a deploy, or a second instance. The record of what has been accepted has to be as durable as the acceptance itself.

Checking after the effect. Writing the trade and then noticing the duplicate leaves you reversing an obligation that has already been communicated. A reversal is a worse outcome than a rejection, because by then the counterparty has seen a confirmation.

Mint the token with the quote
the server issues it, so every attempt for that quote carries the same key, including replays the client never saw
Scope it to that quote
single use, that counterparty, until the quote expires or the transition completes, so a captured token cannot be replayed elsewhere
Insert the key and the acceptance together
one transaction, durable storage, a uniqueness constraint on the key, before any effect is applied
Apply the transition only if the insert won
a conflict means this attempt is a repeat, so nothing runs twice
Return the first attempt's answer
same acceptance, same identifier, same terms. An error on a repeat converts a solved problem back into a support incident
Do not hash the payload. Two separate decisions with identical fields collapse into one, and a body differing only by a timestamp produces two keys for one decision.
Do not keep seen keys in memory. The record of what has been accepted has to be as durable as the acceptance.
Do not check after the effect. By then the counterparty has seen a confirmation and you are reversing an obligation.
A safe retry path is what lets the client retry aggressively, which is what makes the product usable on a bad connection. Those are one design, not three.
FigureThe key identifies the decision rather than the request, which is why it is minted with the quote and not with the call, and the check sits inside the same atomic unit as the transition so the database resolves the race instead of your code. Reading first and then writing lets two concurrent attempts both find nothing, which is exactly what two taps on a flaky connection produce.

A repeat is a success, not an error

The last piece is the response, and it is the piece most often got backwards.

When a repeat arrives, the right answer is the answer the first attempt got. The same acceptance, the same identifier, the same confirmed terms. Not a conflict, not an error, not a message about a duplicate request.

The reason is the caller's situation. A client retrying on timeout cannot tell the difference between "your first attempt succeeded" and "your attempt failed" if the repeat returns an error. It will surface a failure to someone whose trade is live, who will then try to accept again, or ring the desk, or walk away believing nothing happened. An error on a repeat converts a solved problem back into a support incident.

Returning the original outcome makes the whole path safe to retry, which is what lets the client retry aggressively, which is what makes the product usable on a bad connection. Those three things are one design, not three.

The honest limit

Idempotency on accept stops one agreement becoming two obligations. It does not stop two genuine agreements, and it should not. If a counterparty accepts one quote and then accepts a second quote on identical terms, those are two decisions and two trades, and no key can distinguish them from the outside. That distinction has to live in commercial rules such as credit limits and position checks, not in the idempotency layer.

It also does nothing about the inverse failure, where an acceptance is lost and nobody notices. A key makes repeats safe, but something still has to confirm to the counterparty that their decision is recorded, and something still has to raise an alarm when the accept path stops producing trades at its usual cadence.

And it only protects the transitions that carry it. A platform where accept is idempotent and cancel, amend and reallocate are not has protected the action someone thought about and left the rest exposed.

FAQ

Why does accepting a quote need an idempotency key when accepting is just one button? Because the button will be pressed once and the request will arrive more than once. A tap made on poor signal appears to do nothing, so the person taps again. Clients retry on timeout and proxies replay. Without a key checked before the transition is applied, the second arrival creates a second binding trade against a counterparty who believes they agreed once.

Should the client or the server generate the key? The server, as part of issuing the quote. The key needs to identify the decision rather than the attempt, and a client that generates a fresh value on each tap produces a different key for the same decision. Issuing an acceptance token with the quote means every attempt for that quote carries the same key, including replays the client never saw.

Can I use a hash of the request body as the key? No. Hashing the payload collapses two genuinely separate decisions that happen to have identical fields into one, which silently loses a real trade. It also produces two keys when the body differs only by a timestamp or a client version. The key should be minted once with the quote and carried unchanged by every attempt.

What should the server return when the same acceptance arrives twice? The same result the first attempt received, with the same identifiers and terms. A conflict or error response leaves a retrying client unable to tell success from failure, so it surfaces a failure to someone whose trade is actually live. Returning the original outcome is what makes the path safe to retry at all.

Is disabling the button after the first tap enough? No. It handles the case where the app stays alive and responsive, and misses the app being backgrounded, the page reloading, the person reopening to try again, and every replay generated by infrastructure after the client has finished. The duplicate can be created in the middle of the network, where the client has no say in it.

Where exactly should the key be checked? In the same atomic unit as the transition, against durable storage, before any effect is applied. A uniqueness constraint on the key, inserted in the same transaction as the acceptance, lets the database resolve the race. Reading first and then writing fails under concurrency, and an in-process cache of keys already seen fails on restart or on a second instance.

Carrying a program like this one?

Tell us the system, the stakes, and the date that matters. You get a straight technical reply from the person who would lead the work, within 24 hours.

Bring us the program