Learn / Lesson 5

Retries and double payment

Keep the original payment, reconcile pending settlement, and check authorization expiry before considering a replacement. [1]

quotum x402 per-request intake remains closed. Do not sign a per-request payment. [2]

A timeout leaves a question

In the default x402 authorization flow, verification comes before the resource handler, settlement follows the handler, and then the server responds. A connection lost after settlement can leave the caller without the reply. Do not interpret that interruption as evidence that the payment failed. [1]

The notes distinguish authorization from upfront: if the handler fails in authorization, the client is not charged; upfront can charge even without delivery. Establish the flow before reasoning about a failure. [1]

Keep one signed authorization

On EVM exact with EIP-3009, the signed fields include from, nonce, validAfter and validBefore. The nonce is a random 32-byte value. Verification is read-only and must not write chain state. Replay protection uses on-chain nonce consumption, the time window and the signature. A fresh nonce can authorize a separate payment; a lost reply is no reason to sign again. [1]

The client described in the notes sets validAfter to zero and validBefore to now plus the offer’s maxTimeoutSeconds. The deadline is a timestamp, not a duration. Losing the HTTP reply does not invalidate the signature. This toy treats now >= validBefore as expired and does not model validAfter or establish expiry on chain. [1]

Before considering a replacement, reconcile the original payment and establish expiry or on-chain cancellation. EIP-3009 includes cancelAuthorization. Cancelling a local request is not that chain action. Expiry does not prove that an earlier transfer failed. [1]

Pending means reconcile

If errorReason is settlement_pending, settlement is broadcast but unconfirmed. It is non-terminal and must carry a transaction hash. Reconcile on chain before retrying; do not turn pending into failure. [1]

The receipt checker in lesson 2 labels this state PENDING. It does not certify settlement from an unsigned header. Keep the accepted offer, original authorization and transaction reference together for reconciliation. [1]

Read the meaning behind the status

For quotum prepaid project requests only: reuse the same Idempotency-Key with the same project and body. A duplicate returns 202 while open, or 200 when closed with a receipt and no model output. That 200 does not replay the inference body. A changed body with that key returns 409. A project or key daily budget returns 429. [2]

The documented wallet route returns 409 payment_already_used when an included or confirmed payment is resent without a recovery capability. This differs from the prepaid changed-body conflict. On that wallet route, 202 includes Retry-After while payment, fulfillment or refund is pending. Wait and reconcile; 202 proves neither delivery nor refund and does not authorize another payment. [2]

These wallet-route rules describe behavior while x402 per-request intake remains closed. The prepaid Python examples set max_retries=0 on the OpenAI SDK client. Keep that client’s retries off. For a manual retry, preserve the same Idempotency-Key and body, and inspect the receipt when a stream breaks. [2]

Try it

Simulate a dropped HTTP response after settlement. Resending the same identifier and body returns the stored sample receipt, does not replay model output, and leaves one sample charge. A fresh nonce with a fresh identifier adds a second sample charge. A fresh nonce with the original identifier and the same body returns the stored receipt and does not add a charge. [2]

Without a cached receipt, an expired window or used nonce causes a local refusal with no additional charge. A cached receipt submits no authorization. These refusals are not HTTP 402 responses. In x402, 402 can carry payment requirements or report payment failure; it does not prove the original authorization is dead. Reconcile first. The replacement gate requires explicit unpaid reconciliation and expiry or confirmed cancellation. It checks supplied booleans; it obtains no chain evidence. All values are fictional and no signature is created. [1]

Simulate a dropped HTTP response. Fictional service, no payment.
  1. Sign
  2. Send
  3. Verify
  4. Work
  5. Settle
  6. Reply
Choose a cut point, then run the sample.

No sample run yet.

Replacement gate: blocked while the authorization is live.

Run it

Self-contained JavaScript sample. Run here or save the module and run it with Node. No network or credentials.

// Fictional service. Nonces and identifiers are labels, never signatures.
export function createService() {
  const used = new Set(), cache = new Map();
  let charges = 0;
  return {
    send({ nonce, id, body, validBefore = 100, now = 10, loseReply = false }) {
      if (id && cache.has(id)) {
        const saved = cache.get(id);
        return saved.body === body ? '200: sample receipt; output not replayed' : '409: sample identifier mismatch';
      }
      if (now >= validBefore) return 'LOCAL REFUSAL: expired sample authorization; not an x402 402 offer';
      if (used.has(nonce)) return 'LOCAL REFUSAL: used sample nonce; not an x402 402 offer';
      used.add(nonce); charges++;
      if (id) cache.set(id, { body });
      return loseReply ? 'TIMEOUT: reply lost after settlement' : '200: sample answer';
    },
    charges() { return charges; }
  };
}
export function mayReplace({ now, validBefore, cancelledOnChain = false, reconciled = false, originalSettled }) {
  const expired = Number.isSafeInteger(now) && Number.isSafeInteger(validBefore)
    && now >= 0 && validBefore >= 0 && now >= validBefore;
  return reconciled === true && originalSettled === false && (expired || cancelledOnChain === true);
}
export function run() {
  const api = createService();
  const request = { nonce: 'SAMPLE_NONCE', id: 'SAMPLE_ID', body: 'sample question' };
  return [
    api.send({ ...request, loseReply: true }),
    api.send(request),
    api.send({ ...request, body: 'changed question' }),
    api.send({ ...request, id: undefined }),
    api.send({ nonce: 'EXPIRED_NONCE', body: 'sample', now: 100, validBefore: 100 }),
    'CHARGES AFTER RECOVERY: ' + api.charges(),
    api.send({ ...request, nonce: 'FRESH_NONCE' }),
    'CHARGES WITH ORIGINAL IDENTIFIER: ' + api.charges(),
    api.send({ ...request, nonce: 'FRESH_NONCE', id: 'FRESH_ID' }),
    'CHARGES WITH FRESH NONCE AND IDENTIFIER: ' + api.charges(),
    'REPLACE WHILE LIVE: ' + mayReplace({ now: 10, validBefore: 100, reconciled: true, originalSettled: false }),
    'REPLACE AFTER EXPIRY AND RECONCILIATION: ' + mayReplace({ now: 100, validBefore: 100, reconciled: true, originalSettled: false })
  ];
}
if (typeof process !== 'undefined' && process.argv[1]?.endsWith('/retries-and-double-payment.mjs')) console.log(run().join('\n'));
Save sample module
The sample has not run yet.

Check yourself

The original authorization is still live and the reply was lost. Should you sign a replacement now? [1]

Read the explanation

A lost reply does not invalidate the original authorization. [1]

Does 409 always mean the same recovery action? [2]

Read the explanation

The docs distinguish changed-body conflicts and payment_already_used. [2]

settlement_pending includes a transaction. Is it failed? [1]

Read the explanation

Pending is non-terminal and may still confirm. [1]

Sources

  1. x402 evidence baseline, 3 November 2026. Version 2 specification at the pinned commit, EVM exact, ERC-3009.
  2. quotum receipts, seat keys and Pay as you go. Site copy captured 5 November 2026.

Written by the team that sells capped inference keys. Check the cited docs and the chain yourself.