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]
- Sign
- Send
- Verify
- Work
- Settle
- 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'));
The sample has not run yet.
