x402 Explained: HTTP‑Native Micropayments for AI Agents (With Real Code)
TL;DR – x402 is a lightweight extension to HTTP that lets a server respond with
402 Payment Requiredand a JSON‑encoded payment request. An AI agent can fulfill the request by signing a transaction with a crypto wallet, then retry the original request with a proof‑of‑payment header. The flow stays inside the normal request/response loop, so existing HTTP tooling (proxies, caches, middleware) works unchanged.
1. Why a payment‑status code?
HTTP already defines status codes for client (4xx) and server (5xx) errors. 402 Payment Required was reserved in RFC 7231 but never standardized for a concrete protocol. x402 fills that gap with a minimal, extensible payload:
| Header | Meaning |
|---|---|
402 Payment Required |
Server cannot service the request until payment is supplied. |
X-PAYMENT-REQUEST |
Base64‑url JSON describing amount, currency, destination address, and a nonce. |
X-PAYMENT-RESPONSE (client → server) |
Base64‑url JSON containing a signed transaction hash (or any proof the verifier accepts). |
X-PAYMENT-VERIFIER (optional) |
URL of a service that can validate the proof (useful for off‑chain verification). |
Because the payment data lives in headers, the body of the original request/response is untouched—ideal for agents that already exchange JSON or binary payloads.
2. End‑to‑end flow
Agent → Server (GET/POST …)
No payment header → server returns402+X-PAYMENT-REQUEST.Agent decodes the request, builds a transaction that pays the specified amount to the destination address (usually a smart‑contract escrow or a simple EOA), signs it with its wallet, and extracts the transaction hash (or a Merkle proof if using roll‑ups).
Agent → Server (retry)
AddsX-PAYMENT-RESPONSE: <base64url(signedProof)>header and repeats the original request.Server verifies the proof (on‑chain call or off‑chain verifier). If valid, it processes the request and returns
200 OK(or another appropriate status). If invalid, it returns402again with a new nonce to prevent replay attacks.
The entire exchange is stateless from the server’s perspective except for the nonce, which prevents replay without requiring server‑side session storage.
3. Minimal server implementation (Node.js + Express)
// server.js
const express = require('express');
const crypto = require('crypto');
const app = express();
const PORT = 3000;
// In‑memory nonce store – replace with Redis or DB in prod
const nonces = new Map();
// Helper: generate a payment request payload
function makePaymentRequest() {
const nonce = crypto.randomBytes(16).toString('hex');
nonces.set(nonce, Date.now() + 5 * 60 * 1000); // 5‑min TTL
return Buffer.from(
JSON.stringify({
amount: '0.05', // USDC amount (string to avoid float issues)
currency: 'USDC',
chainId: 8453, // Base
destination: '0x1234...abcd', // your wallet or escrow contract
nonce,
})
).toString('base64url');
}
// Middleware to verify payment response
function verifyPayment(req, res, next) {
const respHeader = req.get('X-PAYMENT-RESPONSE');
if (!respHeader) return next(); // let the route handler decide
try {
const payload = JSON.parse(Buffer.from(respHeader, 'base64url').toString());
const { nonce, txHash } = payload;
const expiry = nonces.get(nonce);
if (!expiry || expiry < Date.now())
return res.status(402).set('X-PAYMENT-REQUEST', makePaymentRequest()).send('Expired or unknown nonce');
// ----> REAL VERIFICATION LOGIC <----
// For demo we just check that txHash looks like a hex string.
// In production you would:
// 1. Call a JSON‑RPC endpoint (Base) to get transaction receipt.
// 2. Confirm status == 1, to == destination, value == amount (in wei).
// 3. Optionally verify via an off‑chain verifier service.
if (!/^0x[a-fA-F0-9]{64}$/.test(txHash))
throw new Error('malformed txHash');
// Consume nonce to prevent replay
nonces.delete(nonce);
next(); // payment verified
} catch (e) {
return res.status(402)
.set('X-PAYMENT-REQUEST', makePaymentRequest())
.send('Invalid payment proof');
}
}
// Example protected endpoint
app.get('/ai/generate', verifyPayment, (req, res) => {
// Your actual AI work goes here – placeholder:
const result = { text: 'Generated response for paid request.' };
res.json(result);
});
// Public health endpoint (no payment needed)
app.get('/health', (req, res) => res.send('OK'));
app.listen(PORT, () => console.log(`x402 server listening on :${PORT}`));
Key points
- The server never stores wallet keys or signs transactions – it only validates proofs.
- Nonces are short‑lived; a production deployment would push them to a fast KV store (Redis, DynamoDB) with automatic expiry.
- The verification step is deliberately marked as a placeholder; the real work is an on‑chain call or a call to a trusted verifier service.
4. Agent client snippet (Python + web3.py)
# agent.py
import os
import base64
import json
import time
import requests
from web3 import Web3
from eth_account import Account
# Configuration
BASE_RPC = os.getenv("BASE_RPC", "https://base.mainnet.rpc.cloud")
WALLET_PRIVKEY = os.getenv("WALLET_PRIVKEY") # never commit this!
ACCOUNT = Account.from_key(WALLET_PRIVKEY)
WEB3 = Web3(Web3.HTTPProvider(BASE_RPC))
SERVER = "http://localhost:3000/ai/generate"
def b64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
def b64url_decode(s: str) -> bytes:
padding = 4 - len(s) % 4
if padding == 4:
padding = 0
return base64.urlsafe_b64decode(s + '=' * payment)
def fetch_payment_request() -> dict:
"""First call – expect 402 with X-PAYMENT-REQUEST."""
r = requests.get(SERVER, timeout=10)
if r.status_code != 402:
raise RuntimeError(f"Unexpected status {r.status_code}: {r.text}")
req_b64 = r.headers["X-PAYMENT-REQUEST"]
return json.loads(b64url_decode(req_b64))
def build_and_sign_tx(req: dict) -> str:
"""Create a USDC transfer transaction on Base and sign it."""
usdc_address = Web3.to_checksum_address("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913") # USDC on Base
usdc_abi = [{"constant":False,"inputs":[{"name":"_to","type":"address"},{"name":"_value","type":"uint256"}],"name":"transfer","outputs":[{"name":"","type":"bool"}],"type":"function"}]
usdc = WEB3.eth.contract(address=usdc_address, abi=usdc_abi)
amount_wei = int(float(req["amount"]) * (10 ** 6)) # USDC has 6 decimals
txn = usdc.functions.transfer(
Web3.to_checksum_address(req["destination"]),
amount_wei
).build_transaction({
"chainId": req["chainId"],
"gas": 80000,
"gasPrice": WEB3.eth.gas_price,
"nonce": WEB3.eth.get_transaction_count(ACCOUNT.address),
})
signed = ACCOUNT.sign_transaction(txn)
return signed.transactionHash.hex()
def send_payment_proof(proof: dict):
"""Retry original request with X-PAYMENT-RESPONSE."""
headers = {"X-PAYMENT-RESPONSE": b64url_encode(json.dumps(proof).encode())}
r = requests.get(SERVER, headers=headers, timeout=10)
r.raise_for_status()
return r.json()
def main():
req = fetch_payment_request()
print("Payment request:", req)
tx_hash = build_and_sign_tx(req)
print(f"Submitted tx {tx_hash}")
# In a real agent you would wait for confirmation or rely on a mempool listener.
# For demo we assume instant inclusion (not safe on mainnet!).
proof = {"nonce": req["nonce"], "txHash": tx_hash}
result = send_payment_proof(proof)
print("AI response:", result)
if __name__ == "__main__":
main()
Explanation of the client
- The first
GETtriggers the402. The agent parses the header, builds a USDC transfer, signs it, and extracts the hash. - The proof consists of the nonce (to bind the proof to the request) and the transaction hash.
- The agent retries the request with
X-PAYMENT-RESPONSE. The server verifies the hash on‑chain (in a real implementation) and, if successful, returns the AI output.
Note
Top comments (0)