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)

Why a new status code?

HTTP already defines a range of client‑error codes (4xx). The 402 Payment Required status was reserved in the original spec but never widely used. Recent work in the “x402” proposal repurposes it for micropayments that travel inside the HTTP request/response cycle. The idea is simple:

  1. A server that wants to charge for a resource replies 402 with a payment‑request payload.
  2. The client (here, an autonomous AI agent) reads that payload, constructs a blockchain transaction that satisfies the request, and retries the original request with a payment proof attached in a header.
  3. The server verifies the proof, and if valid, returns the requested data with a 200 OK.

Because the payment flow lives in HTTP, no extra RPC layer or custom protocol is needed—any HTTP client can participate, and the agent can stay completely stateless between calls.

Core components

Component Role
Resource server Exposes an endpoint that may return 402. Holds a public key (or contract address) to verify payments.
Payment request JSON placed in the X-Payment-Request header of a 402 response. Contains: amount, token contract, chain ID, recipient, nonce, and expiry.
Payment proof Signed transaction (or receipt) placed in the X-Payment-Proof header of the retried request. The server checks the signature, nonce, and that the transaction succeeded on‑chain.
Wallet / signer Holds the agent’s private key; used to build and sign the payment transaction.
Blockchain RPC Used only for verification (read‑only calls) and to submit the payment transaction. No persistent connection is required.

All of this can be implemented with a few lines of code; the heavy lifting is done by existing Ethereum‑compatible libraries.

Server side (Node.js/Express)

// server.js
import express from 'express';
import { ethers } from 'ethers';
import crypto from 'crypto';

const app = express();
const PORT = 3000;

// Configuration – in practice load from env or a secret manager
const RECIPIENT = '0xRecipientAddress…'; // USDC on Base
const USDC_CONTRACT = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; // Base USDC
const CHAIN_ID = 8453; // Base
const PROVIDER_URL = 'https://base.mainnet.rpc.cloud'; // read‑only RPC
const PRIVATE_KEY = process.env.AGENT_PRIVATE_KEY; // server’s hot wallet for refunds (optional)

const provider = new ethers.JsonRpcProvider(PROVIDER_URL);
const usdc = new ethers.Contract(
  USDC_CONTRACT,
  ['function balanceOf(address) view returns (uint256)',
   'function transfer(address,uint256) returns (bool)'],
  provider
);

// Helper: generate a payment request payload
function makePaymentRequest(amountUSDC, nonce) {
  // amount in smallest USDC units (6 decimals)
  const amountWei = ethers.parseUnits(amountUSDC.toString(), 6);
  return {
    amount: amountWei.toString(),
    token: USDC_CONTRACT,
    chainId: CHAIN_ID,
    recipient: RECIPIENT,
    nonce: nonce.toString(),
    expiry: Math.floor(Date.now() / 1000) + 300, // 5 min window
  };
}

// Middleware to verify a payment proof
async function verifyPayment(req, res, next) {
  const proofHeader = req.headers['x-payment-proof'];
  if (!proofHeader) return res.status(400).send('Missing payment proof');

  let proof;
  try {
    proof = JSON.parse(proofHeader);
  } catch {
    return res.status(400).send('Invalid payment proof JSON');
  }

  // Basic sanity checks
  if (proof.chainId !== CHAIN_ID.toString()) return res.status(400).send('Wrong chain');
  if (proof.token.toLowerCase() !== USDC_CONTRACT.toLowerCase()) return res.status(400).send('Wrong token');
  if (proof.recipient.toLowerCase() !== RECIPIENT.toLowerCase()) return res.status(400).send('Wrong recipient');

  // Re‑construct the transaction from the signature
  const tx = {
    to: proof.token,
    data: usdc.interface.encodeFunctionData('transfer', [proof.recipient, proof.amount]),
    value: 0,
    nonce: parseInt(proof.nonce),
    chainId: parseInt(proof.chainId),
  };

  // Recover signer address from signature
  const txHash = ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(
    ['bytes'], [ethers.RLP.encode(tx)]
  ));
  const signerAddr = ethers.recoverAddress(txHash, proof.signature);
  if (signerAddr.toLowerCase() !== proof.sender.toLowerCase())
    return res.status(401).send('Signature mismatch');

  // Optional: check that tx was mined and succeeded (read‑only)
  const receipt = await provider.getTransactionReceipt(proof.txHash);
  if (!receipt || receipt.status !== 1)
    return res.status(402).send('Payment not confirmed');

  // Nonce replay protection – in a real service you’d store used nonces in Redis/DynamoDB
  // For demo we just accept any nonce; production must enforce uniqueness.
  req.paymentVerified = true;
  next();
}

// Example protected resource
app.get('/ai/summarize', verifyPayment, async (req, res) => {
  const { text } = req.query;
  if (!text) return res.status(400).send('Missing text');
  // Stub: call an LLM (omitted for brevity)
  const summary = text.slice(0, 120) + ''; // pretend we summarized
  res.json({ summary });
});

// If the client hasn’t paid, return 402 with a payment request
app.get('/ai/summarize', (req, res) => {
  // In practice you’d check for a valid payment header first; omitted here for brevity
  const nonce = crypto.randomInt(0, 2**32);
  const paymentReq = makePaymentRequest('0.01', nonce); // $0.01 USDC
  res.set('X-Payment-Request', JSON.stringify(paymentReq));
  return res.status(402).send('Payment required');
});

app.listen(PORT, () => console.log(`Server listening on :${PORT}`));
Enter fullscreen mode Exit fullscreen mode

What the server does

  • On the first request, if no X-Payment-Proof header is present, it returns 402 and includes a payment request in X-Payment-Request.
  • When the client retries with a proof, the verifyPayment middleware:
    • Parses the proof.
    • Re‑creates the transaction data.
    • Recovers the signer address from the supplied signature.
    • Checks that the transaction exists on‑chain and succeeded.
    • Allows the request to reach the handler only after verification passes.

Client side (Python agent)


python
# agent.py
import json
import time
import requests
from eth_account import Account
from eth_account.messages import encode_defunct
from web3 import Web3

# Configuration
AGENT_PRIVATE_KEY = "0xYOUR_PRIVATE_KEY"
AGENT_ADDRESS = Account.from_key(AGENT_PRIVATE_KEY).address
USDC_CONTRACT = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
RECIPIENT = "0xRecipientAddress…"
CHAIN_ID = 8453
RPC_URL = "https://base.mainnet.rpc.cloud"
PROVIDER = Web3(Web3.HTTPProvider(RPC_URL))
USDC_ABI = [
    {"constant":False,"inputs":[{"name":"_to","type":"address"},{"name":"_value","type":"uint256"}],
     "name":"transfer","outputs":[{"name":"","type":"bool"}],"type":"function"}
]
USDC = PROVIDER.eth.contract(address=USDC_CONTRACT, abi=USDC_ABI)

def build_payment_tx(amount_usdc, nonce):
    """Return raw transaction dict for USDC transfer."""
    amount_wei = int(amount_usdc * 1e6)  # USDC has 6 decimals
    txn = USDC.functions.transfer(
        RECIPIENT,
        amount_wei
    ).build_transaction({
        "chainId": CHAIN_ID,
        "from": AGENT_ADDRESS,
        "nonce": nonce,
        "value": 0,
    })
    return txn

def sign_tx(txn):
    signed = Account.sign_transaction(txn, AGENT_PRIVATE_KEY)
    return signed.rawTransaction.hex()

def fetch_with_payment(url, params=None):
    headers = {}
    resp = requests.get(url, params=params, headers=headers)
    if resp.status_code == 402:
        # Parse payment request
        pay_req = json.loads(resp.headers["X-Payment-Request"])
        amount_usdc = int(pay
Enter fullscreen mode Exit fullscreen mode

Top comments (0)