Here's a scenario that's going to happen to your agent: it sends a payment, the network accepts it, the service processes it — and then the response is lost. Your agent has no idea whether it was charged. It retries. Now what?
This isn't a theoretical edge case. When 57 agents hold wallets and 323 services accept payments, timeout-after-payment is a when-not-if problem. The difference between an agent you let run unattended and one you watch nervously is how it handles these states.
This checklist covers the 7 patterns every agent developer should implement before putting real money on the line. Each pattern includes the failure scenario, the fix, and code you can adapt.
replay_lock table with PRIMARY KEY (kind, txHash) ensures any given payment is single-use across the entire gateway. An in-memory LRU provides a fast-path rejection before hitting the database.
// Gateway-side (what minia2a does):
// INSERT OR IGNORE INTO replay_lock (kind, key, created_at)
// VALUES ('payment', $txHash, NOW())
//
// If the INSERT is ignored (duplicate), return 402 replay rejection.
// Otherwise, proceed to verification.
// Agent-side (what you should do):
async function payWithIdempotency(service, amount) {
const tx = await signAndSendPayment(amount);
const txHash = tx.hash; // globally unique
try {
const result = await callService(service, { payment: tx });
return result;
} catch (err) {
if (err.status === 402 && err.body.includes('replay')) {
// Payment already spent — DON'T create a new one.
// Check if you already have a receipt for this txHash.
const existingReceipt = await findReceipt(txHash);
if (existingReceipt) return existingReceipt.result;
throw new Error('Payment spent but no receipt found — investigate manually');
}
throw err;
}
}
GET /x402/gas?chain=ethereum. The receipt proves "paid $0.05 to the gas service." An attacker replays this receipt to claim payment for GET /x402/gas?chain=solana (same service, same amount). If the receipt isn't bound to the full request, the second call looks valid.
(method, path, amount, timestamp, expiry). Verification checks every field — including that the receipt hasn't expired.
// Receipt structure (what the gateway signs):
const receiptPayload = {
id: `rcpt_${Date.now()}_${randomHex(8)}`,
service_id: 'x402-gas',
agent: '0x7Bd0...',
amount_cents: 5,
currency: 'USDC',
// Request binding — these fields make the receipt non-transferable:
method: 'GET',
path: '/x402/gas?chain=ethereum',
request_hash: sha256(JSON.stringify({method, path, body})),
tx_hash: '0x9f3a...',
issued_at: Date.now(),
expires_at: Date.now() + 120_000 // 2 minutes
};
// HMAC-SHA256 over canonical JSON
const signature = hmacSha256(
JSON.stringify(receiptPayload, Object.keys(receiptPayload).sort()),
GATEWAY_SECRET
);
// Agent-side verification:
function verifyReceipt(receipt, expectedRequest) {
if (Date.now() > receipt.expires_at) return { valid: false, reason: 'expired' };
if (receipt.method !== expectedRequest.method) return { valid: false, reason: 'method mismatch' };
if (receipt.path !== expectedRequest.path) return { valid: false, reason: 'path mismatch' };
if (receipt.amount_cents !== expectedRequest.amount) return { valid: false, reason: 'amount mismatch' };
const expectedSig = hmacSha256(
JSON.stringify(receipt.payload_fields_sorted),
KNOWN_GATEWAY_KEY
);
if (!timingSafeEqual(receipt.signature, expectedSig)) return { valid: false, reason: 'bad signature' };
return { valid: true };
}
// Agent-side timeout handler:
async function callWithTimeout(service, params, opts = {}) {
const timeout = opts.timeout || 30_000;
const payment = await signAndSendPayment(opts.maxAmount);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const res = await fetch(`https://minia2a.uk/x402/${service}`, {
signal: controller.signal,
headers: {
'x402Version': '2',
'x402Payment': JSON.stringify(payment)
}
});
clearTimeout(timer);
return await res.json();
} catch (err) {
clearTimeout(timer);
if (err.name === 'AbortError') {
// TIMEOUT — check if payment was actually processed
const receipt = await queryReceiptByTxHash(payment.txHash);
if (receipt) {
// Payment succeeded, receipt exists. Try to retrieve the result.
if (receipt.result_available) {
return await fetchResultByReceipt(receipt.id);
}
// Receipt exists but result not cached — this is the gap.
// Your agent has paid but can't get the result without paying again.
logAccountingAnomaly(payment, receipt, 'paid_no_result');
throw new PaymentProcessedButResultLostError(payment, receipt);
}
// No receipt — payment likely never reached the gateway.
// Safe to retry with a NEW payment (old one is unspent).
return await callWithTimeout(service, params, opts);
}
throw err;
}
}
// Retry decision matrix:
const RETRY_STRATEGY = {
// Pre-verification errors → same payment is safe
'ECONNREFUSED': { action: 'retry_same_payment', maxRetries: 3 },
'ENOTFOUND': { action: 'retry_same_payment', maxRetries: 3 },
'ETIMEDOUT': { action: 'check_receipt', maxRetries: 1 },
'ECONNRESET': { action: 'retry_same_payment', maxRetries: 2 },
// HTTP-level
408: { action: 'check_receipt', maxRetries: 1 }, // Request timeout
429: { action: 'retry_same_payment', maxRetries: 5 }, // Rate limit
502: { action: 'retry_same_payment', maxRetries: 2 }, // Bad gateway (pre-verify)
503: { action: 'retry_same_payment', maxRetries: 3 }, // Overloaded (pre-verify)
504: { action: 'check_receipt', maxRetries: 1 }, // Gateway timeout (may be post-verify)
// x402-specific
'402_replay': { action: 'do_not_retry', maxRetries: 0 }, // Payment already spent
'402_insufficient': { action: 'do_not_retry', maxRetries: 0 }, // Not enough funds
'402_expired': { action: 'new_payment', maxRetries: 1 }, // Receipt expired
};
function decideRetry(error, attemptNumber) {
const code = error.httpStatus || error.code;
const strategy = RETRY_STRATEGY[code] || { action: 'escalate', maxRetries: 0 };
if (attemptNumber > strategy.maxRetries) return { action: 'escalate' };
return strategy;
}
// Agent-side budget guard:
class AgentBudget {
constructor(dailyCapCents = 500) { // $5/day default
this.dailyCap = dailyCapCents;
this.spentToday = 0;
this.date = new Date().toDateString();
this.allowlist = null; // null = allow all, Set = allow only these
}
canSpend(amountCents, serviceId) {
// Check date rollover
const today = new Date().toDateString();
if (today !== this.date) { this.spentToday = 0; this.date = today; }
// Check daily cap
if (this.spentToday + amountCents > this.dailyCap) {
return { allowed: false, reason: `daily cap: spent ${this.spentToday}/${this.dailyCap}c` };
}
// Check allowlist
if (this.allowlist && !this.allowlist.has(serviceId)) {
return { allowed: false, reason: `service ${serviceId} not in allowlist` };
}
// Check per-call ceiling (never pay more than 1000c = $10 in a single call)
if (amountCents > 1000) {
return { allowed: false, reason: `per-call cap: ${amountCents}c exceeds 1000c` };
}
return { allowed: true };
}
recordSpend(amountCents) {
this.spentToday += amountCents;
}
status() {
return {
spentToday: this.spentToday,
remaining: this.dailyCap - this.spentToday,
dailyCap: this.dailyCap,
utilization: `${Math.round(this.spentToday/this.dailyCap*100)}%`
};
}
}
// Always verify the receipt before using the response:
async function verifiedCall(service, params) {
const budget = getAgentBudget();
const priceCheck = await fetch(`https://minia2a.uk/x402/${service}?trial=1`);
const price = priceCheck.headers.get('x402Price'); // e.g., "5 USDC"
const spendCheck = budget.canSpend(parseInt(price), service);
if (!spendCheck.allowed) throw new Error(spendCheck.reason);
const payment = await signPayment(parseInt(price));
const response = await fetch(`https://minia2a.uk/x402/${service}`, {
headers: {
'x402Version': '2',
'x402Payment': JSON.stringify(payment)
}
});
const body = await response.json();
// CRITICAL: verify receipt before trusting the result
if (!body.receipt) throw new Error('No receipt in response');
const verification = verifyReceipt(body.receipt, {
service_id: service,
amount_cents: parseInt(price),
method: 'GET',
path: `/x402/${service}`
});
if (!verification.valid) {
throw new Error(`Receipt verification failed: ${verification.reason}`);
}
// Receipt verified — safe to record spend and use the result
budget.recordSpend(parseInt(price));
await persistReceipt(body.receipt); // audit trail
return body.result;
}
// Circuit breaker for payment rail:
class PaymentCircuitBreaker {
constructor(opts = {}) {
this.failureThreshold = opts.failureThreshold || 5;
this.cooldownMs = opts.cooldownMs || 120_000; // 2 minutes
this.failures = 0;
this.lastFailure = 0;
this.state = 'closed'; // closed → open → half-open
}
async call(fn) {
if (this.state === 'open') {
if (Date.now() - this.lastFailure > this.cooldownMs) {
this.state = 'half-open';
} else {
throw new CircuitOpenError(`Payment rail in cooldown (${Math.round((this.cooldownMs - (Date.now() - this.lastFailure))/1000)}s remaining)`);
}
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (err) {
this.onFailure();
throw err;
}
}
onSuccess() {
this.failures = 0;
this.state = 'closed';
}
onFailure() {
this.failures++;
this.lastFailure = Date.now();
if (this.failures >= this.failureThreshold) {
this.state = 'open';
}
}
}
// Usage with multi-facilitator fallback:
const breakers = {
cloudflare: new PaymentCircuitBreaker(),
coinbase: new PaymentCircuitBreaker(),
circle: new PaymentCircuitBreaker()
};
async function payWithFallback(service, amount) {
const facilitators = ['cloudflare', 'coinbase', 'circle'];
for (const f of facilitators) {
try {
return await breakers[f].call(() => payVia(service, amount, f));
} catch (err) {
if (err instanceof CircuitOpenError) continue; // try next
if (err.isFatal) throw err; // don't retry fatal errors
continue; // try next facilitator
}
}
// All facilitators failed — degrade gracefully
const cached = await getCachedResult(service);
if (cached && !cached.isStale) {
log.warn(`All payment rails down, using cached ${service} (age: ${cached.age}s)`);
return cached.data;
}
throw new AllFacilitatorsDownError('No payment rail available and no valid cache');
}
| Pattern | One-Sentence Rule | Without It |
|---|---|---|
| 1. Idempotency | Every payment is globally single-use | Double charges on retry |
| 2. Request-Bound Receipts | Receipt proves payment for this specific request | Receipt replay across different calls |
| 3. Timeout Recovery | Check receipt status before re-paying | Pay twice for one result |
| 4. Retry Safety | Pre-verify errors = same payment; post-verify = check receipt | Orphaned payments stuck in replay lock |
| 5. Budget Guardrails | Daily cap + per-call ceiling + allowlist | Agent spends $1,000 in a loop overnight |
| 6. Receipt Verification | Verify signature + fields + expiry before trusting result | Trusting MITM-forged responses |
| 7. Graceful Degradation | Circuit breaker + cache fallback + multi-facilitator | Agent keeps paying failed transactions or uses stale data silently |
Two gaps remain in the current x402 payment infrastructure that agent developers should be aware of:
Bottom line: The payment rails work. $50B in cumulative x402 volume, Cloudflare Wallets live, 9 active facilitators. The infrastructure is ready. What separates a demo from production is not the protocol — it's how your agent handles the 2% of calls where something goes wrong. This checklist covers those cases.
Test each pattern with real payments (start with $0.05 calls). Your agent will encounter every failure mode on this list eventually. Better to find them during development than at 3 AM when your agent has already spent $50 retrying a dead service.
Published by minia2a.uk — 323 services, USDC on Base, facilitator-agnostic. 15 free trials on every endpoint. Agent integration guide →