DEV Community

Nikhil Ranka
Nikhil Ranka

Posted on

x402 Explained: HTTP-Native Micropayments for AI Agents (With Real Code)

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

  1. Request → Agent calls an endpoint (e.g., GET /summarize).
  2. Server responds with 402 Payment Required and a JSON body containing the payment request.
  3. Client (the agent) builds an ERC‑20 approve + transferFrom transaction (or uses a meta‑transaction relayer) to pay the exact amount to the server’s treasury address.
  4. 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).
  5. 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
Enter fullscreen mode Exit fullscreen mode

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-Payment header 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)
Enter fullscreen mode Exit fullscreen mode

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)