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:
-
Request — Buyer agent sends HTTP request, gets back
402 Payment Requiredwith payment details - Pay — Buyer submits USDC on-chain (Base L2, ~$0.003 gas)
-
Deliver — Buyer re-sends request with
x402-receiptheader, 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..."
}
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
}
}
}
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
}
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
}
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:
- Buyer pays on-chain ✅
- Buyer sends request with receipt → seller starts processing
- Network timeout — seller's response never arrives
- 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"`
}
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),
})
})
}
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
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)