DEV Community

minia2a
minia2a

Posted on • Originally published at minia2a.uk

How to Make Agent Payments Reliable — Receipt Idempotency, Timeout Recovery, and Avoiding Double Charges in x402

A reader of my earlier x402 tutorial asked a set of questions that every engineer building on agent payment protocols eventually hits:

"Bind each payment proof to the request method, path, amount, and a short expiry, then reject a reused receipt. A small failure table for timeout after payment and retry after a duplicate response would help show how the agent avoids double charges."

These aren't edge cases. They're the difference between a payment flow that works in a demo and one you'd trust an autonomous agent to use unsupervised. Let's walk through each problem and the patterns that solve it.

The Core Problem: Agent Payments Are Eventually Consistent

An x402 payment has three phases:

  1. Request — Buyer agent sends HTTP request, gets back 402 Payment Required with payment details
  2. Pay — Buyer submits USDC on-chain (Base L2, ~$0.003 gas)
  3. Deliver — Buyer re-sends request with x402-receipt header, seller verifies on-chain confirmation, returns result

Each phase can fail independently. Phase 2 can succeed on-chain while Phase 3 never completes. Phase 3 can be replayed with the same receipt. The seller can crash after confirming payment but before returning the result. An agent needs to handle all of these without a human watching.

Pattern 1: Receipt Binding — What Should a Receipt Prove?

The x402 receipt standard binds to:

{
  "request_body_hash": "sha256(...)",
  "timestamp": "2026-08-07T17:00:00Z",
  "signature": "0x..."
}
Enter fullscreen mode Exit fullscreen mode

This proves: "someone paid for this exact request body at this time."

But it doesn't prove which endpoint the payment was for. A receipt from GET /search?q=cats is technically valid for GET /search?q=dogs if both have empty bodies — the body hash matches.

The Fix: Request Context Binding

Add a request_context extension to the receipt:

{
  "request_body_hash": "sha256(...)",
  "timestamp": "2026-08-07T17:00:00Z",
  "signature": "0x...",
  "extensions": {
    "request_context": {
      "method": "GET",
      "path": "/search",
      "query_params_hash": "sha256(q=cats)",
      "amount_cents": 10
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Now the receipt is bound to a specific operation, not just a body. The seller can reject a receipt that doesn't match the current request's method, path, and amount.

Implementation (seller middleware, Go):

func verifyReceiptContext(receipt x402.Receipt, r *http.Request, expectedAmount int) error {
    ctx, ok := receipt.Extensions["request_context"]
    if !ok {
        return errors.New("missing request_context extension")
    }
    if ctx.Method != r.Method {
        return fmt.Errorf("method mismatch: receipt=%s request=%s", ctx.Method, r.Method)
    }
    if ctx.Path != r.URL.Path {
        return fmt.Errorf("path mismatch")
    }
    expectedQuery := sha256Hash(r.URL.RawQuery)
    if ctx.QueryParamsHash != expectedQuery {
        return fmt.Errorf("query params mismatch")
    }
    if ctx.AmountCents != expectedAmount {
        return fmt.Errorf("amount mismatch")
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Receipt Replay Protection — The Idempotency Cache

Once a receipt is valid, an attacker (or a buggy agent) could replay it. Without protection, the seller executes the same paid operation twice, and the buyer is charged once but gets two results.

The Fix: Receipt Nonce Cache

The seller stores sha256(receipt_signature) with a TTL matching the receipt's expiry window:

type ReceiptCache struct {
    mu    sync.RWMutex
    store map[string]CachedResult
}

type CachedResult struct {
    Response   []byte
    StatusCode int
    ExpiresAt  time.Time
}

func (rc *ReceiptCache) CheckOrStore(sigHash string, ttl time.Duration) (*CachedResult, error) {
    rc.mu.Lock()
    defer rc.mu.Unlock()

    if cached, exists := rc.store[sigHash]; exists {
        if time.Now().Before(cached.ExpiresAt) {
            return &cached, ErrDuplicateReceipt // 409 Conflict
        }
        delete(rc.store, sigHash) // expired
    }
    return nil, nil // new receipt, proceed
}

func (rc *ReceiptCache) Store(sigHash string, result CachedResult) {
    rc.mu.Lock()
    defer rc.mu.Unlock()
    rc.store[sigHash] = result
}
Enter fullscreen mode Exit fullscreen mode

The key insight: a replayed receipt returns 409 Conflict with the original response. The buyer gets the result they paid for; the seller doesn't re-execute. No double charge, no double execution.

Pattern 3: Timeout After Payment — The Hardest Case

The sequence:

  1. Buyer pays on-chain ✅
  2. Buyer sends request with receipt → seller starts processing
  3. Network timeout — seller's response never arrives
  4. Buyer doesn't know: did it work or not?

The Fix: Idempotent Retry with Receipt as Key

The buyer retries with the same receipt. The seller, having cached the result, returns it from the cache (Pattern 2 handles this). If the seller never completed processing (crashed mid-execution), the receipt signature isn't in the cache yet, so the seller re-executes.

But here's the subtlety: side-effectful operations. If the endpoint sends an email or submits a transaction, re-execution is dangerous.

The solution is an explicit idempotency_key:

type x402Request struct {
    IdempotencyKey string `json:"idempotency_key,omitempty"`
    Payload        json.RawMessage `json:"payload"`
}
Enter fullscreen mode Exit fullscreen mode

For idempotent operations (GET, pure computation): the receipt signature is the idempotency key.
For side-effectful operations (POST/PUT with external effects): the buyer generates a unique idempotency_key before the payment flow, includes it in the body hash, and the seller uses it to deduplicate.

Pattern 4: The Failure Table

The reader asked for "a small failure table for timeout after payment and retry after a duplicate response." Here it is:

Scenario Buyer Action Seller Behavior Buyer Gets
Payment confirmed, response received Done Normal execution 200 + result
Payment confirmed, response timeout Retry with same receipt Replay from cache (409) or re-execute 409 + cached result
Payment confirmed, seller crashed before caching Retry with same receipt Re-execute (receipt not in cache) 200 + result
Receipt replayed by bug Normal request Cache hit → 409 409 + original result
Receipt expired (TTL passed) New payment required Cache miss → 402 402 Payment Required
Payment sent to wrong address Never gets to seller N/A Buyer wallet shows sent; seller never sees it

The last row is an on-chain verification problem, not a protocol problem — the seller only accepts receipts with on-chain confirmation to their address.

Implementation: A Complete Seller Middleware

Putting it all together, here's the seller-side middleware:

func x402Middleware(next http.Handler) http.Handler {
    cache := NewReceiptCache()

    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        receiptHex := r.Header.Get("x402-receipt")
        if receiptHex == "" {
            // No receipt → return 402 with payment details
            w.Header().Set("x402-payment", buildPaymentDetails(r))
            http.Error(w, "Payment Required", 402)
            return
        }

        receipt, err := verifyReceipt(receiptHex, r)
        if err != nil {
            http.Error(w, "Invalid receipt: "+err.Error(), 403)
            return
        }

        sigHash := sha256Hash(receipt.Signature)
        cached, err := cache.CheckOrStore(sigHash, 5*time.Minute)
        if err == ErrDuplicateReceipt {
            w.Header().Set("x402-idempotent", "true")
            w.WriteHeader(cached.StatusCode)
            w.Write(cached.Response)
            return
        }

        // Execute the actual handler
        rw := &responseRecorder{ResponseWriter: w}
        next.ServeHTTP(rw, r)

        // Cache the result
        cache.Store(sigHash, CachedResult{
            Response:   rw.body,
            StatusCode: rw.statusCode,
            ExpiresAt:  time.Now().Add(5 * time.Minute),
        })
    })
}
Enter fullscreen mode Exit fullscreen mode

What This Means for Agent Autonomy

An agent can now handle payments reliably:

1. POST /classify → 402 Payment Required
2. Pay $0.10 USDC on Base → tx confirmed
3. POST /classify + x402-receipt → 200 OK { "label": "spam" }
4. [Same POST + same receipt] → 409 Conflict { "label": "spam" }  ← no double charge
5. [POST + receipt, network timeout] → retry with same receipt → 409 + cached result
Enter fullscreen mode Exit fullscreen mode

No human watches this loop. No double charges. No lost payments. The agent pays for exactly what it uses.


The patterns here are what we run in production at minia2a. The receipt nonce cache handles ~387K requests without a double-charge incident. The request_context extension is on our spec wishlist — it's the next logical step for receipt binding. If you're building on x402 and hitting these same problems, the idempotency cache is the highest-leverage change you can make.

Thanks to Swapnoneel Saha for the questions that prompted this writeup. Good engineering questions make better documentation.

Top comments (0)