← Blog
November 3, 2026 · 11 min

x402 Buyers Sign Twice: Hold, Reconcile, or Pay Again

A buyer sends a signed payment, the connection drops, and the response never arrives. Did it pay? The reference x402 fetch client does not ask. It signs again. On main today, one purchase can become two valid authorizations.

This is not a seller bug or a facilitator bug. Every component can behave correctly and the buyer still pays twice. That makes it a client-side property, and a client-side property needs a client-side fix.

We reproduced the behaviour on the current x402-foundation/x402 main (a17c668a, 2 November 2026). Then we read the open fix, PR #4094, ran the same harness against its head, and checked where it still leaves a gap.

The question the issue asked

Issue #3438 was opened on 9 September 2026 and is still open, with 12 comments. It asks three things. Is per-call signing the caller's problem by design? If not, where should reuse live? Should the "Payment already attempted" guard run before createPaymentPayload instead of after?

The code answers the first question by omission. In the EVM exact client, createEIP3009Payload draws a random 32-byte nonce on every call and sets validBefore to now + maxTimeoutSeconds. Nothing in packages/core/src/client remembers a payload it already built. Two calls for one purchase produce two distinct replay identities, and every replay guard in the stack correctly admits both.

That is right for two purchases. It is wrong for one purchase retried.

Where the second signature comes from

Read wrapFetchWithPayment on main in order:

  1. Send the request. If it is not a 402, return it.
  2. Parse the 402, run hooks, then call client.createPaymentPayload.
  3. Only now check whether the cloned request already carries PAYMENT-SIGNATURE or X-PAYMENT. If so, throw "Payment already attempted".
  4. Send the paid request.
  5. Hand the result to processPaymentResult. If a hook says recovered, build a fresh payload with createPaymentPayload again and send once more.

Two things follow. The guard in step 3 bounds an infinite loop but does not prevent a signature. And the recovery branch in step 5 signs a second authorization by design, without asking whether the first one landed.

Then there is the caller. A paid attempt that ends in a timeout, a 5xx, or a new 402 looks like failure. Agent frameworks retry on failure. The wrapper keeps no memory between calls, so the caller's retry starts at step 1 and ends at step 2 with a new nonce.

Why the timing matters — an EIP-3009 authorization stays presentable until validBefore. While the buyer signs and sends authorization #2, authorization #1 is still valid. If the facilitator settled #1 and only the response was lost, both can settle. On Solana the same window is the blockhash lifetime, roughly two minutes.

Reproducing it on main

We built a small harness in the fetch package. It uses the real x402Client and wrapFetchWithPayment, a stub exact scheme that mints a distinct nonce per call, and a mock fetch acting as seller plus facilitator. Unpaid requests get a 402. Paid requests count as settled, because we model a facilitator that broadcast the transfer, and then the response is sabotaged in one of three ways. The caller invokes the wrapped fetch twice for the same URL.

// mock: facilitator settled, response lost
if (!sig) return new Response("{}", { status: 402, headers: { "PAYMENT-REQUIRED": encodePaymentRequiredHeader(pr) } });
seen.push(sig); settled++;
if (mode === "throw") throw new Error("ETIMEDOUT");
if (mode === "502")   return new Response("bad gateway", { status: 502 });
return new Response("{}", { status: 402, headers: { "PAYMENT-REQUIRED": encodePaymentRequiredHeader(pr) } });

Results on main, two caller invocations per mode:

Two overlapping calls for the same URL, launched together, also signed twice.

The caveat matters. We counted signatures sent, and we assumed the first one settled. We did not run a chain. The harness shows that main can hand a seller two distinct valid authorizations for one purchase. Whether both settle depends on the facilitator, which is the whole point: the buyer cannot know.

One side effect we did not exercise but can read in the source: because the guard runs after signing, a request that is about to be refused still produces a signed authorization and fires onBeforePaymentCreation hooks. PR #4093 (12 additions, 5 deletions, 3 files) moves the guard above creation. Spend trackers that hang their debits on that hook count a payment that never happens.

What PR #4094 changes

The PR is 645 additions and 43 deletions across 13 files, stacked on #4093. It has three layers.

// Layer 1 — core

An optional reconcile hook on the scheme

SchemeNetworkClient gains an optional reconcilePaymentPayload that returns "settled", "unsettled" or "unknown". It is exposed through x402Client and x402HTTPClient. Existing schemes are unaffected.

// Layer 2 — EVM

One read against the token contract

The exact EVM client implements it as a single authorizationState(from, nonce) call on the asset. That function exists in the reference USDC implementation (authorizationState(address authorizer, bytes32 nonce) returns (bool)) and returns true once a nonce has been used or canceled. It runs when the signer exposes readContract or an RPC URL is configured. Otherwise the answer is "unknown". Permit2 payloads also answer "unknown".

// Layer 3 — fetch

Hold the payload per purchase

The wrapper stores the signed payload under a key of method, URL and body. A retry of the same purchase re-presents the held payload while accepted.maxTimeoutSeconds is open. It signs fresh only in two cases: the facilitator returned a reason code with nothing broadcast, or the window closed and reconcile confirmed the nonce is unused. If a re-presented payment still is not delivered and settlement cannot be ruled out, it throws PaymentOutcomeUnknownError carrying the payload and the last response.

The design decision worth noticing is the error. The old behaviour returned a retryable 402. The new one refuses to return a retryable-looking answer when the money may already be gone. It forces the caller to treat "outcome unknown" as a state, not as a failure.

It also takes a position on free text. When the facilitator call throws, the resource server copies the exception message into errorReason, for example Facilitator settle failed (502): bad gateway. A buyer cannot tell that from a facilitator verdict. So only a snake_case reason code with an empty transaction counts as a definite failure. settlement_pending and anything containing nonce_already_used are explicitly treated as open or settled.

The same harness against the PR

We checked out the PR head (f5b58455), rebuilt @x402/core, and ran the identical three modes. Our stub scheme has no reconcile method, so the result is "unknown" throughout. That is the conservative path.

On the paths we tested, the double signature is gone. The PR's own numbers, which we did not rerun, are 5 of 14 double-settling runs on the release client against 0 of 14 on the branch in its own harness, and 7 double-pays in a 10-mode battery from the third-party hostile-facilitator tool on 2.28.0 against none on the branch. The author notes the four reconcile_* modes of that tool expect a non-standard /reconcile endpoint on the facilitator, while this branch checks on chain, so they report "held, but never asked".

What is still open

The overlapping-call result is the one we can speak to directly. We ran two concurrent calls for the same URL against the PR head and both signed: two payloads, two distinct signatures. A reviewer on the PR reported the same finding at the same commit. The hold is written after createPaymentPayload resolves, so a second call that arrives during signing misses it. A per-key in-flight promise set before creation would close the window. This matters for agents specifically, because agent runtimes fan out tool calls in parallel.

The same reviewer reported a second case that we did not reproduce, so treat it as their claim. A 500 carrying PAYMENT-RESPONSE with errorReason set to upstream_timeout and an empty transaction passes the snake_case test. The hold is dropped and a second payload is signed. Their point is that snake_case is a naming convention, not a contract, and the resource server can wrap an arbitrary exception into that shape. An explicit list of pre-broadcast reason codes would fail closed.

Three more limits are stated in the PR itself.

And a limit that is structural. Reconcile only works where the chain can answer. EIP-3009 gives a one-call read. Permit2 payloads give nothing in this PR. SVM has no equivalent in the diff. Without an answer, the safest behaviour is the thrown error, which is correct but pushes the decision to a human or an orchestrator.

The cost of waiting out the window

There is a quiet trade-off in "sign fresh only after the window closes". The window is maxTimeoutSeconds, chosen by the seller. A buyer that must not re-sign until it passes will be stuck for that long on an ambiguous purchase. A seller advertising 600 seconds is asking buyers to wait up to ten minutes before retrying with certainty. Sellers who want fast recovery should advertise short windows. Buyers should read the field as a retry budget, not a formality.

We have already written about the seller side of this gap in the recovery extension audit and about what "success" means per chain in the settlement finality audit. This post is the third leg: what the buyer does when neither of those answers arrives.

What it means for LLM4Agents

The platform sits on both ends of x402. Agents call our gateway and pay per request, and many of those agents use exactly this client library or one like it. The double-sign problem is therefore a risk to our users' balances before it is a risk to ours.

On the buyer side, an autonomous agent is the worst case for this bug. It retries without a human, it runs tool calls in parallel, and it has no intuition that "502 after paying" is different from "502 before paying". The first time it matters will be an overnight loop that pays twice per flaky response.

On the seller side, the exposure is reputational and operational. A buyer who paid twice for one chat completion will open a dispute with us, not with their SDK. Our spend-control analysis covered the per-payment cap, which is checked on each signature. Nothing in it counts how many signatures one purchase produces. This bug lives in that gap.

Staying on the frontier

  1. Test our own client path with the harness above. Run the three modes and the concurrent case against whichever fetch or axios wrapper our documented quickstarts recommend. Record signatures sent per purchase. Do this before anyone else's report arrives.
  2. Document the hold in our agent recipes now. Until #4094 or an equivalent ships in a release, show a small caller-side wrapper: key by method, URL and body, hold the payload for the window, serialize overlapping calls with an in-flight promise, and treat 5xx, timeouts and fresh 402s after a paid attempt as "unknown", not "failed".
  3. Make "outcome unknown" a first-class state in our SDK examples. Return it to the agent as a distinct result, with the payload and last response attached, so the planner can query a balance or a receipt instead of retrying.
  4. Give buyers a reconcile signal. If our gateway can answer "was this authorization nonce settled" for a given payment signature, expose it. That works on every chain and removes the need for the buyer to run an RPC read. Do it as a read-only endpoint with the same key as the payment identifier.
  5. Serve the original result for a repeated signature. This is the receiver half in #3437 and the replay-ledger idea in our earlier recovery post. It turns "paid once, not delivered" into delivered.
  6. Review our advertised maxTimeoutSeconds. It is also the buyer's retry budget. Shorter is better for recovery, as long as signing and settlement fit inside it.
  7. Watch #4094, #4093, #3437 and #3438. Merge order and the answer to the "by design" question will decide whether the hold lives in the SDK or in every integrator's code. Track the axios follow-up separately.

Build agents that pay once

Register an agent on LLM4Agents and pay per request in stablecoins over an OpenAI-compatible gateway.

Register agent