DEV Community

Bryant rudolph
Bryant rudolph

Posted on

Preventing double-purchases in agentic commerce — the retry-storm problem

An agent retries a tool call. The tool call was a purchase. The merchant receives two valid orders. The agent's ledger records one transaction. The buyer's statement shows two.

This is the most common failure mode in agentic commerce, and it isn't a bug in any single component. It's the predictable result of three design decisions that were each reasonable on their own.

Agents retry. Reliable agent design requires retry logic. Network calls fail, timeouts happen, models hallucinate transient errors. The standard remedy is to retry until the call succeeds.

Purchases are non-idempotent by default. A POST to create an order creates an order. A second POST creates a second order. Nothing distinguishes "this is a retry of the same intent" from "this is a new purchase."

Settlement is asynchronous. The agent's tool call returns when the order is acknowledged, not when the payment settles. Between acknowledgment and settlement, the agent has no signal that the purchase is already in flight.

The race condition: retry fires before the settlement confirmation arrives. The agent sees no successful response to the first attempt. It retries. The merchant processes both.

Why this is different from consumer double-charges
Consumer double-charges are typically a payment-processor bug — the same authorization submitted twice by accident. The consumer notices on their statement and calls the bank. The card network has a straightforward remedy: reverse the duplicate.

Agent double-purchases are structural, not accidental. The agent was designed to retry. The merchant was designed to accept valid requests. The payment rail was designed to settle what it receives. Everyone behaved correctly within their own boundary. The failure lives in the gap between them — the missing record of what the agent was actually trying to commit to.

The partial fixes that don't fully work
Idempotency keys work when the client generates the key and has a stable identity for "the same intent." Agents often don't.

Nonce-based authorization (EIP-3009) prevents double-settlement. It doesn't prevent double-order — the merchant may have already accepted both orders before either authorization reaches the chain.

Spend limits bound the damage. They don't prevent the double-purchase.

Tool-call deduplication works in-process. It fails across session restarts, process boundaries, or model context resets.

Each of these addresses part of the problem. None addresses the record that makes the duplicate detectable in the first place.

What's actually missing
A commitment identifier generated before the purchase attempt that both the agent and the merchant treat as authoritative.

This is different from an idempotency key. An idempotency key is generated by the client and honored by the server. A commitment identifier is generated as part of the commitment itself — before the agent attempts the purchase, before the merchant receives the request, before either party has committed to anything.

The commitment record binds:

The specific intent (what the agent is trying to buy)

The authority (what the agent is allowed to buy, under what limits)

The terms (price, deliverable, deadline, acceptance criteria)

The unique commitment identifier that ties the retry to the original intent

When the agent retries, the retry carries the same commitment identifier. When the merchant receives the second request, it recognizes it as the same commitment. When the payment rail receives the second authorization, the commitment record prevents the duplicate settlement — because the commitment is the unit of truth, not the individual purchase attempt.

How I'm building this
I've been building a protocol layer for this problem. It's called SAAX Protocol — Solvent Applied Autonomous Exchange Protocol. A vendor-neutral commitment lifecycle for autonomous commerce.

A SAAX commitment includes a unique commitment ID, the authority reference, the terms, the evidence requirements, and the recovery policy. When the same intent is retried, the retry references the same commitment ID. The merchant, the agent, and the settlement rail all work from the same record.

The spec is public at saax-protocol.com/spec. The conformance suite is at saax-protocol.com/conformance. There's a working MCP endpoint at https://www.saax-protocol.com/mcp that handles discovery, routing, and validation — free to test, no signup.

The protocol is vendor-neutral and open for comment. Would be interested in feedback from anyone working on this problem — especially where the spec is thin or unclear.

Curious how others are handling retry-safe agent purchases. What's your approach?

Top comments (2)

Collapse
 
chrissellers profile image
Chris Sellers •

The gap framing is the part that sticks: everyone behaved correctly inside their own boundary, and the double purchase still happened. That is exactly why spend limits and in-process dedup feel like progress until a session restart or a timeout reopens the hole.

One edge I would add to the commitment model: what the agent does when the first attempt leaves the machine and never returns a response. The commitment ID is already on the wire, settlement may already be in flight, and a naive retry with the same ID still races the merchant accept path unless both sides treat an unknown outcome as a status check, not as another POST.

Curious where you put that rule in the SAAX commitment: does the agent have to ask the merchant for commitment state before any retry is allowed, or does the merchant reject a second accept while a commitment is still in flight?

Collapse
 
brudy_dev24 profile image
Bryant rudolph •

Both. The agent must ask before retrying, and the merchant must reject a second accept while a commitment is in flight. Neither rule closes the gap alone.

Agent-side: before any retry, the agent calls get_commitment_status with the commitment ID. The response includes a next_action field — retry_safe for not_found, poll for in_flight, stop for fulfilled, retry_with_new_id for failed or expired. An unknown outcome is a status check, not another POST.

Merchant-side: the merchant writes in_flight to durable storage before processing the request. The write is the lock. A second request with the same commitment ID returns in_flight — it isn't processed again.

The merchant-side rule is what closes the race. The agent can only check status after the merchant has committed to a state, so if the merchant hasn't written in_flight yet, the agent's status check returns not_found and retries into the same race. The durable checkpoint is what prevents that.

Both rules are in the spec — the in-flight section is at saax-protocol.com/spec.