Mohamed Kamal
← All posts

Your idempotency keys won't save you from a re-planning agent

Earlier this year I built a double-entry payments ledger in Go. It does the classic thing every payments API has done since Stripe popularized it: every request that moves money carries an idempotency key, and the same key can never move money twice. This week, after DoorDash opened its dd-cli agent beta, I went and read the current agentic-commerce specs to check an uncomfortable suspicion. The suspicion held. The mechanism I built, the one we have all been building for a decade, is structurally blind to how AI agents actually fail.

What the classic mechanism promises

Here is the heart of it, from my ledger’s Postgres store. Before applying a transfer, inside the same transaction that locks the accounts:

if existing, ok, err := readTransferByKey(ctx, tx, req.IdempotencyKey); err != nil {
    return err
} else if ok {
    if !ledger.MatchesPost(existing, req) {
        return ledger.ErrIdempotencyConflict
    }
    result = existing // same request retried: return the original, move nothing
    return nil
}

Same key, same request: you get the original result back, and no money moves. Same key, different request: a hard conflict error, because someone is misusing a key. A unique index on the key turns even a concurrent race into a safe replay. My test suite hammers this with racing duplicates and asserts that money is conserved every time.

Notice the assumption doing all the load-bearing work: the client knows two requests are the same operation. A mobile app knows, because the user tapped Pay once and everything after that is the same attempt being retried. The key travels with the retry. The contract holds.

The client that re-plans

An AI agent is the first payment client that breaks that assumption, because an agent that fails does not merely retry. It thinks again.

Walk through it. Your agent orders dinner. The checkout call times out somewhere between its request and the merchant’s response. A classic client would resend the same bytes with the same key, and the dedup would catch it. But an agent is a planning loop, not a retry loop. It re-reads its context, re-reasons, and may rebuild the whole order: slightly different cart, different quantity, maybe a different restaurant because the first one now shows a longer wait. Out comes a new request, structurally different, with a freshly generated key.

Both charges are legitimate as far as every layer of the stack can tell. There is nothing to deduplicate, because at the request level they are genuinely different requests. The duplication happened one level up, in the intent, where no key exists.

A deterministic client retries with the same idempotency key and the server replays the original result: one charge. A re-planning agent rebuilds the order and submits it with a fresh key, which the server accepts as a new sale: two charges. a client that retries request · key 7f3a timeout, retry retry · same key 7f3a key seen: replay original one charge an agent that re-plans order A · key 7f3a timeout re-plans: rebuilds the cart order B · key c41d new key: new sale accepted two charges
Classic dedup assumes the retry looks like the original. A re-planning agent breaks that assumption.

To be honest about the evidence: I could not find a single published postmortem of this exact failure charging a real customer twice. Practitioner writing describes the mechanism consistently, and payments-infrastructure vendors like Formance now state plainly that with agent payments “retries are normal,” not an edge case. The best-documented real incident so far is adjacent, not identical: OpenAI’s Operator bought groceries without user confirmation during what was supposed to be a price comparison. The incident reports for re-plan duplicates will come. The mechanism is too plain for them not to.

What the protocols actually do about it

This is the part that surprised me. I expected to find the problem ignored. Instead I found the major specs converging, quietly and from different directions, on the same two-layer architecture.

Layer one survives. The Agentic Commerce Protocol (OpenAI and Stripe, the spec behind Instant Checkout) did not abandon request-level keys. It did the opposite: as of the April 2026 release, Idempotency-Key is mandatory on every POST, with a full replay contract, three distinct error codes, and merchant certification that literally tests replay behavior before production access. The spec defines request equivalence as semantic JSON equality of the body, and same-key-different-body is a hard 422 conflict. If that sounds familiar, it is the same contract as the nine lines of Go above. The classic layer got promoted, not retired.

But it is scoped to what it can actually do. For everything the keys cannot see, ACP adds a different primitive: the delegated payment token, single-use by specification, bound to one merchant, one checkout session, a maximum amount, and an expiry. A re-planned duplicate order sails past the idempotency layer, and then needs a payment token it does not have, because the first order consumed it.

Google’s AP2 goes further in the same direction. There is no request-level idempotency key anywhere in that protocol. I searched the spec repo: zero occurrences. Instead the whole design rests on cryptographically signed mandates carrying the user’s actual instructions, with budgets, amount ranges, and recurrence caps, plus a blunt behavioral rule for the agent itself: it must not present a new mandate while a previous overlapping one has no rejection receipt, enforced by deterministic code holding the signing key outside the LLM. And the spec is honest about the residual gap. It states, in writing, that “consideration must be given to how multiple duplicate orders can be prevented.” The people designing the future of payments consider this open.

Visa and Mastercard, meanwhile, moved the check into the network itself. Mastercard’s Intent API registers what the consumer authorized, with an amount ceiling, an expiry, and a merchant binding, and both merchants and issuers validate transactions against the registered intent. Visa validates that each authorization matches the Passkey-authenticated payment instruction. Single-use cryptograms and nonce windows handle replay below that.

One pattern runs through all of it: request-level keys dedupe identical retries; intent-level constraints cap the damage from everything keys cannot see. Nobody solved semantic deduplication. Everybody bounded its blast radius instead.

Two lanes descend through two defense layers. An identical retry with the same key is stopped by layer one, idempotency keys, and replayed with no charge. A re-planned order with a fresh key passes through layer one and is bounded by layer two, intent constraints such as single-use tokens, budgets, and spend caps. identical retry · same key re-planned order · fresh key layer 1 · idempotency keys dedupes identical retries replayed · no charge passes through layer 2 · intent constraints single-use token · budget · spend caps bounded: token already spent
No layer dedupes a re-planned order; the second layer caps what it can spend.

What I would ship differently

Reading the specs sent me back to my own ledger with three conclusions.

First, keys stay, exactly as they are. Every protocol that matters made them mandatory. They are the floor, not the ceiling.

Second, derive keys instead of generating them, where the platform allows it. The practitioner pattern that actually helps is hashing the key from stable identifiers, conversation ID, tool-call ID, normalized arguments, rather than minting a random UUID per attempt. It only catches the re-plan when the rebuilt request comes out identical, but that is not nothing, and it costs one line.

Third, and this is the one I find satisfying as a ledger person: the new intent layer is not exotic. A per-agent spend cap is an account. Fund a sub-account with the agent’s budget, let every agent purchase debit it, and double-entry does the rest: the cap enforces itself, overdraft is impossible by construction, and the audit trail is the account history you already have. An authorization hold against that sub-account, reserve now, capture on confirmation, is my ledger’s existing Authorize/Capture flow, and it is functionally the same shape as ACP’s single-use allowance: reserve a bounded amount, settle once, expire the rest. The boring machinery was already in the building. It just needs to be wired to a new kind of customer.

The agent era does not retire the old discipline. It stacks a second one on top: constrain what any single authorization can do, because you can no longer assume you know how many times it will be attempted, or in what form.

If you are building an agent surface right now, I would genuinely like to know: what are you doing about the re-plan case?

تمّت ✿ Next: The next customer is an agent →