← Blog
October 21, 2026 · 14 min

x402 recovery: the paid answer that arrives for free

The most common failed paid call is not a failed payment. It is a payment that settled while the response never arrived. A proposed x402 extension, PR #3848, tries to standardize what the seller does next. We built the failure in a harness with the official 2.28.0 Express middleware, and one rule in the draft turned out to be the whole game.

Picture an agent that signs a $0.001 authorization, the seller runs the handler, the facilitator settles, and the TCP connection dies before the body reaches the buyer. The money is gone. The answer is not in hand. What should the retry do?

x402 today has three partial answers. The buyer can sign again and pay twice. The facilitator can refuse the old authorization because its nonce is used. Or the seller can implement its own idempotency, which the protocol neither forbids nor describes. A new draft, PR #3848 with its companion issue #3847, proposes a fourth: a recovery extension. We read the spec at commit 5ac11fc (version 0.4, dated 2026-10-20), then ran every claim that could be run.

What the draft proposes

The PR was opened on 2026-10-18 by a32116150-ctrl, who runs a small paid service called verify402. It adds one file, specs/extensions/extension-recovery.md, 412 lines. No SDK code changes. As of 2026-10-21 it is open, with no maintainer review and no labels. A bot comment at creation flagged an unsigned commit and said the PR is closed after one week of inactivity. The author says the draft was written with AI assistance and reviewed by a human.

The spec has three parts.

// Part 1

Evidence bundle

Every paid response carries requestHash and responseHash, both SHA-256 over canonical JSON (keys sorted recursively, no whitespace). The response hash is computed after removing a declared set of keys, excludedKeys. That set is the "projection". The point is that a third party can recompute both hashes from the bytes.

// Part 2

Settled-response recovery

If the caller sends the same idempotency key (default header x-idempotency-key), the seller returns the recorded response without charging again, up to maxReplays times within ttlSeconds of settlement. After the window, a tombstone says "this operation was charged before" and the paywall is re-armed.

// Part 3

The fail-closed rule

The normative core, quoted from the draft: replayable recovery records MUST be gated on confirmed settlement. A failed, aborted or pending settlement must never leave a replayable free result.

Parts 1 and 2 are conveniences. Part 3 is where sellers get hurt, so that is where we spent the harness time.

The harness

We installed @x402/core, @x402/express, @x402/evm and @x402/fetch at 2.28.0, the version the draft's cited independent reproduction used. The facilitator is a mock that models one property of EIP-3009: an authorization nonce can settle once. A second settle with the same nonce returns authorization_used. We can also make it fail for a chosen resource. No chain is involved, so nothing here is a claim about live settlement.

First, a fact about the middleware that decides everything else. In @x402/express the order inside a paid request is: verify, run the route handler with the response buffered, and only then call processSettlement. If settlement fails, the buffered body is dropped and the client gets a 402. If the handler returns a status of 400 or more, the middleware cancels instead of settling. So any state a handler writes exists before settlement is known. That is the root of the first defect the draft names.

The extension point is the server hook onAfterSettle. Its context carries the settle result and, for HTTP, transportContext.responseBody, the exact bytes about to go out. In our runs it fired only for successful settlements: five hook calls for five successful settles, zero for the failed ones. That is useful, because "failed settle promotes nothing" falls out of the hook's own semantics, as long as the promotion happens there.

Scenario 1: the settle fails after the handler ran

We built two variants of the same route. The naive one records the verdict in the handler under the caller's idempotency key. A replay layer sits in front of the paywall and serves recorded keys for free. The gated one only stages the record in the handler and promotes it from onAfterSettle.

/naive call1 (settle fails)            {"status":402}
/naive call2 same key, no payment      {"status":200,"replayed":true}
/naive successful settlements          {"n":0}
/gated call1 (settle fails)            {"status":402}
/gated call2 same key, no payment      {"status":402,"replayed":false}
/gated successful settlements          {"n":0}

The naive seller delivered a paid answer to a buyer who never paid, with zero successful settlements on the books. Anyone who can make a settlement fail can then read the answer: send a payment the facilitator will reject, then retry with the same key. The gated seller returns the payment wall again, which is the right outcome.

This is the "recovery before settlement" defect the draft credits to an independent reproduction. We reproduced the same class with our own code, and it needs nothing exotic. It is what you get from putting the cache write next to the res.json() call.

It is also a cousin of the problem in our two-phase gap audit: value delivered between verify and settle. There the gap lets a buyer get a first answer for free. Here the gap lets a replay cache turn one unsettled answer into many.

Scenario 2: the settle succeeds and the response is lost

This is the case the extension exists for. We paid on a plain route with no recovery, then retried.

/plain retry re-signs                       status 200, settlements: 2, sameNonce: false
/plain retry resends SAME payload           status 402, settlements: 2
/gated retry same key                       s1 200, s2 200, replayed: true, second payment: none, settlements: 1

Two things to read here. If the buyer signs a fresh authorization, which is what the reference client does on every 402, the seller has no way to know it is the same purchase. Two settlements, one answer received. If the buyer resends the old authorization, the mock facilitator rejects it, as the USDC contract would. The buyer is out of money and gets a 402. With a key and the gated store, the retry costs nothing.

The buyer side of this is an open issue on its own. Issue #3438 points at createNonce() in the EVM client: every call mints a fresh 32-byte nonce, and the fetch wrapper can see more than one 402 per purchase. The ecosystem's replay guards then correctly admit both, because at the protocol level they are two payments. Recovery has to live above the nonce.

Scenario 3: identical output is not the same operation

The first draft matched staged records to settlements by the hash of the response. A reviewer, NSPG13, pointed out the flaw: two different requests can both return {"verdict":"no_match"}. The draft was revised in v0.2 to key promotion on a server-generated operationId and to treat the hash as an extra check.

We built the flaw to see it. Operation B is staged first, with its settle delayed and then failed. Operation A starts 100 ms later and settles. Both produce the same projection, {"verdict":"no_match"}. We ran the hook twice, once promoting every staged record whose hash matches, once promoting by operation id.

promotion by hash: A 200, B 402
promotion by hash: B retry, same key, no payment   200 replayed: true
promotion by opid: A 200, B 402
promotion by opid: B retry, same key, no payment   402 replayed: false
identical projection hashes: true

Promotion by hash handed B a free answer that B never paid for, because A's settlement matched B's staged bytes. Promotion by operation id kept B inert. The v0.4 text makes this a conformance control (§5.4 control 1), and it should be. A plausible implementation passes every happy-path test and fails only this one.

A caveat that matters for judging the severity: B's payload here is an answer to a query B's caller asked, so the leaked value is a verdict the buyer was already going to receive. With per-caller data, a hash-keyed promotion becomes a cross-account leak. That is why the draft also says "equal content is not permission" for its optional content companion.

Scenario 4: the key already has a home, and it is in the payload

The draft introduces a request header, x-idempotency-key. But x402 already ships an idempotency extension, payment-identifier, specified in specs/extensions/payment_identifier.md and implemented in the TypeScript and Go packages, with Python examples. We covered it in the extensions layer audit. The shipped spec already says: same id and same payload returns the cached response, same id and a different payload returns 409, and the id should be bound to a request fingerprint. The recovery draft's conflict rule (§5.2) is the same rule with a payer scope added.

What does not ship is a store. The TypeScript README's server example calls an idempotencyStore that is pseudo-code. We searched the 2.28.0 core, express and extensions distributions for cache, dedup and idempotency logic and found only helpers: generatePaymentId, extractPaymentIdentifier, validators. Every seller writes the store, and therefore every seller can write the Scenario 1 bug.

So we ran the shipped extension through the same pattern. The id sits in PaymentPayload.extensions, so the replay lookup has to parse the PAYMENT-SIGNATURE header before the paywall.

call1 200 {"verdict":"ok"} | call2 200 {"verdict":"ok","replayed":true}
settle calls: 1
nonce1 != nonce2: true
call2 authorization left unsettled: validBefore - now = 300 s, value 1000

It works, and it shows a cost the draft's header design avoids. To retry through payment-identifier, the buyer must sign a new, spendable authorization. The seller serves the replay and never settles it. For 300 seconds, the default window in our run, the seller holds a signed transfer of $0.001 that the buyer meant to be free. A seller that behaves will never touch it. The buyer has to trust that.

The same property is also the best answer to the last open review point. NSPG13 asked, at v0.3, for a verified payer scope before replay, because a caller-supplied address proves nothing. The v0.4 text accepts "a payer-signed server challenge, an authenticated session bound to that payer, or an equivalent proof the payment rail already verifies". An EIP-3009 authorization is exactly a payer signature the rail already verifies, and a facilitator /verify call checks it without settling. The cost is the 300-second authorization above, and one caveat we reasoned about but did not measure: a captured PAYMENT-SIGNATURE header is a bearer proof until its validBefore passes, so a replay path that accepts it must also check expiry. Both designs are workable. Neither is free.

Two mechanisms, and a hook that decides which one you get

v0.3 added a point from a second production operator. The author of scvd.store, commenting on #3847, wrote that their service never writes a cache entry speculatively: the write happens only after settlement is confirmed and the response status is below 300. Because their runtime settles inline before headers go out, there is no staged state at all. The draft now names both: asynchronous stage-then-promote when confirmation arrives through a hook after the body is built, and synchronous write-after-settle when confirmation comes first. We cannot check scvd.store's code; we are repeating their account of it. The design reasoning holds on its own.

In the Express middleware, you get the first mechanism. The body is built before settlement, so a seller needs the stage-then-promote pair: a handler that stages under a server-generated operationId placed in the body, and a hook that promotes on success. Our gated route stays short. The one subtle step is carrying the operation id in the response body under an excluded key, so the hook can read it from responseBody and the hash does not change.

// handler: stage only, never replayable yet
const body = { ...verdict, recovery: { operationId: randomUUID() } };
staged.set(body.recovery.operationId, { key, hash: projHash(body), body });

// hook: promote on the operation's own settlement
rs.onAfterSettle(async ctx => {
  if (!ctx.result.success || !ctx.result.transaction) return;
  const b = JSON.parse(Buffer.from(ctx.transportContext.responseBody).toString());
  const s = staged.get(b.recovery?.operationId);
  if (s && s.hash === projHash(b)) promoted.set(s.key, s.body);
});

What we would change in the draft

The behavioral rules are sound, and the author wisely says the wire shape is unstable. Four points on the design.

First, converge the key. Two idempotency mechanisms in one protocol, a header in one and a payload field in the other, is how sellers end up implementing both inconsistently. The draft should say how recovery composes with payment-identifier, or define itself as the replay-semantics layer on top of that id. Issue #3776 argues from another angle that every correlation artifact must be derived once at ingress and never re-derived downstream. That argues for one key.

Second, the replay budget is an exposure. maxReplays and ttlSeconds are per-operation knobs the seller sets. The draft's own security section calls the key a bearer secret. Defaults should be short and low, and a key that leaves a client in logs should be treated as spent.

Third, the evidence bundle overlaps with signed receipts. The receipts audit covers offer-receipt, which already has a signature format. The bundle's hashes are unsigned and self-reported, which is why the draft says it "does not sign anything itself". Without a signature, a recomputable hash proves consistency, not provenance.

Fourth, define what "confirmed settlement" means per chain. A settle success can be a preconfirmed receipt on one network and a finalized transaction on another, as our finality audit measured. The fail-closed rule is only as strict as the confirmation it waits for.

What it means for LLM4Agents

Our gateway sells model calls over x402, and a model call is the worst case for lost responses: it is slow, so the connection has more time to break, and the answer is expensive to regenerate and not reproducible, since a second sample can differ from the first. A recovery path that returns the same recorded answer is worth more here than on a deterministic lookup. We say that as a property of the workload, not as a statement about any current gateway behavior.

The same property is the threat. Any replay cache on a model route is a free-answer store if its promotion rule is wrong. Scenario 1 shows the bug takes one line to write. Scenario 3 shows that the obvious fix, matching by output, is also wrong. A seller who gets this wrong gives away completions to anyone who can make a settlement fail.

It also changes how we should write buyer guidance. Our wait-or-pay audit already told agents to reuse one payment-identifier across retries of one logical call. Scenario 2 adds the number behind that advice: a fresh signature on a retry is a second settlement, and the seller cannot tell.

Staying on the frontier

In order:

First, port the controls, not the extension. Controls 1 through 4 and 8 from §5.4 are deterministic and need no facilitator. Add them as tests to any seller route we operate that caches or replays: identical output must not cross-promote, a missing or empty settlement reference must not promote, a response without an operation id must not promote, a changed request under the same key must conflict, and a retry with the right address but no proof must not replay.

Second, standardize on payment-identifier for buyer-side retries and put the store behind it, keyed by the id plus a request fingerprint, returning 409 on a mismatch. That follows the shipped spec and avoids a second key.

Third, wire promotion through onAfterSettle with the operation id in the body, as in the snippet above, and never write a replayable record from a handler.

Fourth, decide the proof-of-control mechanism before enabling any replay. Our recommendation is the signed authorization plus a /verify call and an expiry check, with the 300-second exposure documented to buyers. The alternative is an authenticated session, which only fits callers that have one.

Fifth, hold the evidence bundle until the signature story is settled. If recomputable hashes are wanted, ship them inside an offer-receipt payload rather than as a parallel unsigned structure.

Sixth, watch three things upstream: whether #3848 gets a maintainer review before its inactivity window, whether #3438 changes the client so a retry can reuse a payload, and whether the SDK gets a real idempotency store behind payment-identifier. The last would remove most of this problem from every seller at once. Related reading: our Sui audit found the same pattern at the facilitator, where two instances with in-memory dedup reported 20 successes for 10 payments. Idempotency state keeps ending up in the least reviewed layer.

Pay per call, in stablecoins, over an OpenAI-compatible API

One 402, one signature, 345+ models. No subscription, no minimum.

Register your agent