Making x402 Payment Proofs Trustworthy: Receipt Binding, Expiry, and Replay Protection
The Problem: A Receipt Is Not Proof of Payment
In x402, the basic flow is: agent requests a resource → server returns HTTP 402 with payment details → agent pays on-chain → agent retries with payment proof → server verifies and delivers. The "payment proof" that the agent sends on retry is typically a transaction hash or a signed receipt.
The subtle problem: a transaction hash proves that some payment happened at some time, but it doesn't prove what was paid for, at what price, or that the payment hasn't been used before. Without binding, a receipt for a $0.001 CAPTCHA solve is technically indistinguishable from a receipt for a $5.00 AI inference — if the server just checks "does this tx exist on-chain?"
1. Request-Scoped Receipt Binding
A properly bound receipt should tie the payment to the specific request it pays for. The minimum viable binding includes:
| Field | Purpose | Example |
|---|---|---|
method | HTTP method | POST |
path | Request path | /x402/captcha-solve |
amount | Exact amount paid | 100000 (0.001 USDC, 6 decimals) |
recipient | Payee address | 0xf16F...4ECA |
nonce | Server-generated unique value per 402 response | req_a1b2c3d4 |
expires_at | Unix timestamp after which receipt is invalid | 1754770800 |
chain_id | Which chain the payment is on | 8453 (Base) |
The server generates these fields before issuing the 402, stores them, and includes them in the 402 response body. When the agent retries, it echoes these fields alongside the on-chain proof. The server then verifies all three: (a) the on-chain transaction is real, (b) the echoed fields match what was stored, (c) the receipt hasn't been used before.
amount and path match. An agent cannot replay a receipt from 3 days ago — expires_at has passed.
2. Short Expiry Windows
How long should a 402 invitation be valid? The trade-off:
- Too short (under 30s): Agent doesn't have time to sign and broadcast a transaction on Base (~2s block time, but mempool congestion can add 10-15s). Timeout-before-payment becomes common.
- Too long (over 1 hour): Price may have changed. The 402 response quoted $0.003 but the service now costs $0.005. Receipt replay window is unnecessarily wide.
- Sweet spot (2-5 minutes): Long enough for on-chain confirmation on Base (~2s blocks, plus 2-3 confirmations for finality), short enough that price staleness is negligible.
crypto.randomUUID() is sufficient.
3. Explicit Replay Rejection
"Reject reused receipts" sounds simple but the implementation has edge cases. The server needs a nonce store — a set of nonces that have been used. When a retry comes in:
function verify_payment(nonce, tx_hash, expected_amount, expected_path):
// Step 1: Check if this nonce was already consumed
if nonce_store.exists(nonce):
return ERROR("Receipt already used. Nonce: " + nonce)
// Step 2: Verify on-chain transaction
tx = blockchain.get_transaction(tx_hash)
if not tx or not tx.confirmed:
return ERROR("Transaction not found or unconfirmed")
// Step 3: Verify binding fields
if tx.amount != expected_amount:
return ERROR("Amount mismatch: expected " + expected_amount + ", got " + tx.amount)
if tx.recipient != expected_recipient:
return ERROR("Recipient mismatch")
// Step 4: Verify expiry
if now() > stored_request.expires_at:
return ERROR("Receipt expired at " + stored_request.expires_at)
// Step 5: Mark nonce as consumed (atomic)
nonce_store.insert(nonce, tx_hash, now())
// Step 6: Deliver the service
return service_response
The key is that Step 5 (mark consumed) must be atomic with Step 6 (deliver). If the server crashes between marking consumed and delivering, the agent paid but got nothing — the worst failure mode. The fix: store the service response alongside the nonce, so a retry after crash can replay the response.
4. Failure-State Handling: The Double-Charge Problem
Swapnoneel's suggestion for a "failure table" is exactly right. There are two dangerous states where an agent could lose money:
Failure Mode A: Timeout After Payment
Agent sends payment → server marks nonce consumed → server crashes before delivering response → agent retries → server says "receipt already used" → agent has paid but received nothing.
Fix: Store the service response before marking the nonce consumed. On retry with the same nonce, check if a response was cached — if so, return it. If not, the payment never completed and the nonce can be re-consumed.Failure Mode B: Retry After Duplicate Response
Agent sends payment → server delivers response → network drops the response before agent receives it → agent thinks it failed → agent pays again with a new nonce → double charge.
Fix: This is harder — the server can't know the agent didn't receive the response. The agent should implement idempotency keys: include a client-generatedidempotency_key in the request. The server caches responses by idempotency key. If the agent retries with the same key, it gets the cached response (no second charge).
5. The Full Trustworthiness Table
| Scenario | Agent Pays? | Agent Gets Result? | Double Charge? | Protection |
|---|---|---|---|---|
| Normal flow | ✅ Yes | ✅ Yes | ❌ No | Standard 402→pay→retry |
| Tx unconfirmed | ❌ No | ❌ No | ❌ No | Server rejects unconfirmed tx |
| Receipt expired | Agent paid but receipt invalid | ❌ No | ❌ No | Short expiry + agent checks expires_at |
| Receipt replayed | No second charge | ❌ No | ❌ No | Nonce store rejects duplicate |
| Server crash after marking consumed | ✅ Yes (once) | ⚠️ Depends | ❌ No | Cached response or refund path |
| Network drop after delivery | ⚠️ Could be twice | ⚠️ Depends | ⚠️ Possible | Idempotency keys (client-side) |
| Amount mismatch | ❌ No | ❌ No | ❌ No | Binding verification rejects |
What This Means for Agent Developers
If you're building an agent that consumes paid APIs, you should:
- Generate an idempotency key for every paid request. Store it until you receive a confirmed response.
- Check expires_at in the 402 response. If it's too short for your confirmation time, don't pay.
- Track your spending with a budget — don't blindly retry. If you've already paid and the response is cached server-side, a retry with the same idempotency key is free.
If you're building a service that accepts x402 payments, you should:
- Generate bound nonces per 402 response. Store method, path, amount, recipient, expires_at.
- Cache responses by nonce + idempotency key. If a retry arrives with a consumed nonce, check the cache before rejecting.
- Publish your failure handling strategy. Agents need to know what happens if something goes wrong before they trust you with their budget.
Thanks to Swapnoneel Saha for the detailed technical feedback that prompted this post. The receipt binding, expiry, and failure-table suggestions are exactly the kind of engineering rigor that agent payments need at this stage. If you have additional threat models or edge cases, the discussion is open.