x402 Explained: HTTP‑Native Micropayments for AI Agents (With Real Code)
Target audience: developers who are building autonomous AI agents that need to charge or pay for services on‑the‑fly.
1. Why a new HTTP status code?
The web already has a status code for “Payment Required”: 402. It was defined in RFC 2616 but never widely adopted because browsers and servers lacked a concrete payment protocol. The x402 proposal (see the IETF‑draft draft‑ietf‑httpbis‑x402‑00) revives 402 by pairing it with a well‑defined JSON payload that carries:
| Field | Type | Description |
|---|---|---|
scheme |
string | Payment scheme, e.g. "usdc"
|
network |
string | Blockchain network identifier, e.g. "base"
|
amount |
string (decimal) | Amount to pay, in the smallest unit of the token |
token |
string (address) | ERC‑20 contract address (optional if scheme implies a known token) |
payload |
string | Opaque data the payer must sign (usually a nonce + timestamp) |
signature |
string | Ethereum‑style signature (0x…) proving the payer approved the exact amount |
When a server returns 402 x402, the client knows exactly what to pay, where to pay it, and how to prove payment. The flow is HTTP‑native: no extra websockets, no custom SDKs—just a header and a JSON body.
2. Core Mechanics
-
Request → Agent calls an endpoint (e.g.,
GET /summarize). -
Server responds with
402 Payment Requiredand a JSON body containing the payment request. -
Client (the agent) builds an ERC‑20
approve+transferFromtransaction (or uses a meta‑transaction relayer) to pay the exact amount to the server’s treasury address. -
Client re‑sends the original request, adding an
X-Payment: <signature>header (or includes the signature in the request body, depending on the server’s preference). -
Server verifies the signature against the nonce/timestamp it issued, confirms the on‑chain transfer (via an indexer or a trusted relayer), then processes the request and returns
200 OK.
Because the payment data lives in the HTTP response, any generic HTTP client can be upgraded to support x402 with a small middleware layer.
3. Minimal Working Example (Python + FastAPI)
Below is a self‑contained snippet that shows both the server side (issuing a 402) and a client side (paying and retrying). It uses USDC on Base (chain ID 8453) and assumes you have a funded agent wallet.
3.1 Server – issuing the payment request
# server.py
from fastapi import FastAPI, Request, Header, HTTPException
from fastapi.responses import JSONResponse
import json, time, os
from eth_account.messages import encode_defunct
from eth_account import Account
app = FastAPI()
TREASURY = Account.from_key(os.getenv("TREASURY_PRIVATE_KEY")).address
USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" # USDC on Base
NONCE_TTL = 300 # seconds
# In‑memory store for demo; replace with Redis or DB in prod
_nonces = {}
def make_402(amount_usdc: str):
nonce = os.urandom(16).hex()
_nonces[nonce] = time.time() + NONCE_TTL
payload = json.dumps({
"scheme": "usdc",
"network": "base",
"amount": amount_usdc,
"token": USDC_BASE,
"payload": nonce,
})
return JSONResponse(
status_code=402,
headers={"Content-Type": "application/json"},
content={"error": "Payment Required", "x402": json.loads(payload)},
)
@app.get("/summarize")
async def summarize(request: Request, x_payment: str = Header(None)):
# Verify payment if present
if x_payment:
try:
sig = bytes.fromhex(x_payment[2:]) # strip 0x
# Recover signer from the nonce we expects in the request body
data = await request.json()
nonce = data.get("nonce")
if nonce not in _nonces or _nonces[nonce] < time.time():
raise HTTPException(400, "Invalid or expired nonce")
msg = encode_defunct(text=nonce)
signer = Account.recover_message(msg, signature=sig)
if signer.lower() != TREASURY.lower():
raise HTTPException(403, "Signature not from treasury")
# In a real system you’d also confirm the on‑chain transfer here
del _nonces[nonce] # one‑time use
except Exception as e:
raise HTTPException(400, f"Payment verification failed: {e}")
# If we reach here, payment is good – process the request
text = (await request.body()).decode()
return {"summary": text[:120] + "..."}
# No payment header → ask for payment
return make_402("0.05") # $0.05 USDC per call
Explanation
- The server creates a random nonce, stores it with a short TTL, and returns it inside the x402 JSON payload.
- The client must sign that nonce with its private key (or a relayer’s key) and send the signature back in the
X-Paymentheader on the retry. - The server verifies the signature, checks the nonce hasn’t expired, and (in production) would also verify that the corresponding ERC‑20 transfer occurred on‑chain. For brevity the on‑chain check is omitted; see the client side for how to perform it.
3.2 Client – paying and retrying
# client.py
import json, time, requests
from eth_account import Account
from eth_account.messages import encode_defunct
from web3 import Web3
AGENT_KEY = os.getenv("AGENT_PRIVATE_KEY")
w3 = Web3(Web3.HTTPProvider("https://base.mainnet.rpc.dev")) # public RPC
account = Account.from_key(AGENT_KEY)
TREASURY = "0xYourTreasuryAddressHere" # must match server's TREASURY
USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
USDC_ABI = [...] # standard ERC20 abi (approve, transferFrom, balanceOf)
def usdc_contract():
return w3.eth.contract(address=USDC, abi=USDC_ABI)
def pay_and_retry(url: str, amount_usdc: str):
# 1️⃣ First request – get the 402 challenge
resp = requests.get(url)
if resp.status_code != 402:
resp.raise_for_status()
return resp.json()
challenge = resp.json()["x402"]
nonce = challenge["payload"]
# 2️⃣ Sign the nonce
msg = encode_defunct(text=nonce)
signed = Account.sign_message(msg, private_key=AGENT_KEY)
signature = signed.signature.hex()
# 3️⃣ Build and send the ERC20 transfer (using approve + transferFrom)
usdc = usdc_contract()
amount_wei = int(float(amount_usdc) * 1e6) # USDC has 6 decimals
tx = usdc.functions.transferFrom(
account.address,
TREASURY,
amount_wei
).build_transaction({
"chainId": w3.eth.chain_id,
"gas": 100_000,
"gasPrice": w3.to_wei("0.1", "gwei"),
"nonce": w3.eth.get_transaction_count(account.address),
})
signed_tx = account.sign_transaction(tx)
tx_hash = w3.eth.send_raw_transaction(signed_tx.rawTransaction)
w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
# 4️⃣ Retry the original request with the payment proof
headers = {"X-Payment": f"0x{signature}"}
payload = {"nonce": nonce}
retry = requests.post(url, json=payload, headers=headers)
retry.raise_for_status()
return retry.json()
# Example usage
if __name__ == "__main__":
result = pay_and_retry("https://your-agent.example.com/summarize", "0.05")
print(result)
Key points
- The client extracts the nonce from the 402 response, signs it, and attaches the signature via
X-Payment. - It then performs an on‑chain USDC transfer (
transferFrom) from the agent’s wallet to the treasury. In production you might use a relayer or ERC‑4337 account abstraction to avoid the agent needing to hold ETH for gas. - After the transaction is confirmed, the client retries the request; the server verifies the signature and processes the call.
4. Trade‑offs & Practical Considerations
| Aspect | Benefit | Cost / Limitation |
|---|---|---|
| Atomicity | Payment proof is tied to a specific nonce, preventing replay attacks. | Requires the server to track nonces (state) or rely on a short‑lived timestamp + on‑chain check. |
| Latency | Only one extra HTTP round‑trip (402 |
Top comments (0)