Last week I wrote about how we stop an AI agent from paying twice when it retries a paid API call. It got 309 views and 73 comments from 11 people, and most of the comments found a real gap.
This is the follow-up: what each gap was, what we changed, and what is still on the list. Everything below is live on our gateway unless I say otherwise.
The 409 looked final
Lin pointed out that many agent runtimes treat any 409 as terminal. Our payment_in_flight answer said "retry shortly" in prose and nothing else, so a runtime would give up and mint a fresh payment id on the next step.
The in-flight 409 now carries the fields a program can act on:
-
retry_after: 5 seconds -
poll:shop.order.get, the call that shows the order state -
order_id, when the order already exists
The agent guide says it plainly: payment_in_flight is not final. Wait retry_after seconds and send the same id again, or read the order.
A crashed request kept its id hostage
Samii asked what happens when the first request dies outright. I checked, and it was worse than I thought. If our process died mid-request, the payment id stayed "in flight" until its 24 hours ran out. The reconcile job would settle the order from the chain, but the id row never learned about it. A retry could keep getting a 409 for an order that was already paid.
Now an in-flight row older than 180 seconds is resolved from the order itself. 180 is longer than the 120 second x402 payment timeout, so a first attempt that is still alive is never pre-empted. If the order is paid, the retry gets the stored order back with replayed: true. If it is still paying, the retry gets the 409 above. The row heals on the next request instead of waiting a day.
A conflict needed its own signal
Robert's point was that a conflict and an in-flight request mean different things to the client. One says the intent changed, the other says wait. They already had separate codes, but the conflict didn't say why. It now carries reason: payer or reason: request, and we alert on conflicts only. A plain repeat of the same request is normal and stays quiet.
The client has to write before it sends
Launchgatecheck named the assumption under all four server layers: the client can present the same id after a crash. That only holds if the agent stored the id before it sent anything.
The docs now say this as a rule. Store the intent first: the payment id, the quote and the recipient, plus the signed authorization itself. Then send. After a restart, send the same id again and never sign a new authorization for the same quote. The two stored things protect different things. The signed authorization bounds the money, because the single-use nonce fails on chain if it is replayed. The id recovers the work, because it is what gets the original result back. The guide also asks tool adapters to refuse to send a payment that has no stored intent.
A retried lookup cost full price
This one came out of a long thread with Robert. Most of our tools are lookups. Over x402 a duplicated lookup was charged in full, even when the answer came from our cache, because the cache discount only existed on the prepaid balance.
Now, on x402, the same wallet asking the same cacheable lookup again within 120 seconds gets the answer without a second charge. The response carries X-APIbase-Charged: false and the receipt of the original paid call. It is deliberately narrow. It covers reads only, never writes, because two identical orders a minute apart can be exactly what the user wanted. Shop tools and the MPP rail are out of scope. The wallet is the session, so this assumes one wallet per agent, which is what our buyer docs already recommend.
Samii warned about volatile fields: a timestamp or cursor in the arguments makes every retry look new. A tool can now mark such fields so they are left out of the key. Nothing is left out by default, so the worst case of a missing mark is a missed match, not a wrong answer.
Duplicates on one rail had no refund record
The longest thread, with Kami, ended on an asymmetry. On Tempo, money moves before the work runs, so we had built a refund-owed record for failed or duplicate calls. On Base we settle only after the result is ready, so nobody had needed one. The edge case that broke this was a duplicate transfer landing after a failed settle. It would only have been found if someone looked.
There is now one refund-owed ledger for both rails. It records the two cases that matter here, a payment that arrived after the order was already final and a duplicate payment. The refund state shows up on the public receipt and in the order lookup, so the payer can see it.
Still on the list
These are designed and queued, not live yet:
- A targeted check after a failed settle on Base. We know exactly which authorization to look at, so instead of waiting for the periodic reconcile we read that nonce a few times over the next minutes. An inconclusive read stays unknown and falls back to the reconcile. It never becomes failed.
- Automatic refunds from the ledger, paid in batches from a wallet set aside for it, so a payer doesn't have to care which rail they used.
- Getting a paid result back by its receipt for 24 hours, signed by the paying wallet, for the agent that lost its runtime and comes back late.
What we decided not to do
A workflow-level id would merge two honest identical lookups in the same run, so the id stays per tool call. Writes never collapse on what the request looks like, only on an explicit key the agent minted. Per-session limits are easy to dodge because the server sees wallets, not sessions, so the hard boundary stays where Yamrail and I ended up: the wallet the agent pays from, with a cap on it.
Thanks to everyone who commented. Each change above started in that thread, so if you see a gap in this version too, I would like to hear it.




Top comments (2)
the write-the-intent-before-you-send section is the one that decides everything else, since every server-side fix here assumes the client can present the same id after a crash. making the 409 carry retry_after, a poll endpoint and order_id is the right shape, because it turns a terminal-looking status into a resumable one. the refund-owed ledger across both rails is the honest answer for the case where prevention fails anyway.
Agreed, and that is the part we can't enforce from our side. If the agent dies before it writes the intent down, the retry arrives with a new id, and to the server it looks like a fresh purchase.
So the guide now tells agents to persist the id before the first send, and the ledger is there for the runs that skip it. Do you see clients actually doing that write in practice, or mostly holding the id in memory?