Making x402 Payment Proofs Trustworthy: Receipt Binding, Expiry, and Replay Protection

August 9, 2026 · Technical
Context: This post is a technical response to Swapnoneel Saha's excellent feedback on receipt security in x402 agent payments. He raised three concrete concerns: receipt binding scope, replay protection, and failure-state handling. These are exactly the right questions, and they deserve a thorough answer.

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:

FieldPurposeExample
methodHTTP methodPOST
pathRequest path/x402/captcha-solve
amountExact amount paid100000 (0.001 USDC, 6 decimals)
recipientPayee address0xf16F...4ECA
nonceServer-generated unique value per 402 responsereq_a1b2c3d4
expires_atUnix timestamp after which receipt is invalid1754770800
chain_idWhich chain the payment is on8453 (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.

✅ What this prevents: An agent cannot pay $0.001 for a CAPTCHA solve and replay the same tx hash to access a $5.00 service. The server checks 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:

Implementation note: The nonce should be cryptographically random (not sequential) to prevent an attacker from guessing future nonces. A simple 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-generated idempotency_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

ScenarioAgent Pays?Agent Gets Result?Double Charge?Protection
Normal flow✅ Yes✅ Yes❌ NoStandard 402→pay→retry
Tx unconfirmed❌ No❌ No❌ NoServer rejects unconfirmed tx
Receipt expiredAgent paid but receipt invalid❌ No❌ NoShort expiry + agent checks expires_at
Receipt replayedNo second charge❌ No❌ NoNonce store rejects duplicate
Server crash after marking consumed✅ Yes (once)⚠️ Depends❌ NoCached response or refund path
Network drop after delivery⚠️ Could be twice⚠️ Depends⚠️ PossibleIdempotency keys (client-side)
Amount mismatch❌ No❌ No❌ NoBinding verification rejects

What This Means for Agent Developers

If you're building an agent that consumes paid APIs, you should:

  1. Generate an idempotency key for every paid request. Store it until you receive a confirmed response.
  2. Check expires_at in the 402 response. If it's too short for your confirmation time, don't pay.
  3. 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:

  1. Generate bound nonces per 402 response. Store method, path, amount, recipient, expires_at.
  2. Cache responses by nonce + idempotency key. If a retry arrives with a consumed nonce, check the cache before rejecting.
  3. 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.