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 building autonomous AI agents who need a lightweight, on‑chain way to charge per‑call without reinventing billing infrastructure.


1. Why x402 matters for agents

AI agents frequently invoke other services—LLM endpoints, data feeds, tool wrappers—often dozens or hundreds of times per task. Traditional API‑key or subscription models add operational overhead (key rotation, usage metering, invoicing) and are poorly suited for sub‑cent pricing.

x402 is an HTTP status code (402 Payment Required) extension that lets a server signal that a request can be fulfilled only after the client presents a verifiable, on‑chain payment. The flow stays inside the HTTP request/response cycle, so agents can treat a paid call exactly like any other GET/POST: they add a header, retry on 402, and proceed when the header validates.

Key properties:

Property What it means for agents
** Stateless** No server‑side session needed; each request carries its own proof.
** Atomic** Payment verification and service execution happen in the same request; no separate settlement step.
** Chain‑agnostic** Works with any EVM‑compatible chain that supports ERC‑20 tokens (USDC on Base, Polygon, etc.).
** Minimal overhead** Only a few extra bytes (signature + nonce) added to the request header.

2. The protocol in a nutshell

  1. Client sends a normal HTTP request.
  2. Server checks for a valid X402-Payment header.
    • If missing or invalid → respond 402 Payment Required with a WWW-Authenticate‑style challenge that includes:
      • price (amount in smallest token unit)
      • token (ERC‑20 contract address)
      • chainId
      • nonce (server‑generated, prevents replay)
  3. Client builds a payment proof:
    • Assemble the message: keccak256(abi.encodePacked(price, token, chainId, nonce, requestBodyHash))
    • Sign it with an EOA or smart‑wallet private key (eth_sign).
    • Encode the signature (v, r, s) and the signer address into the X402-Payment header.
  4. Server verifies the signature, confirms the signer holds enough balance (or relies on the mempool if using ERC‑4337 paymaster), increments its internal nonce, and processes the request.

Because the proof is tied to the exact request body (via its hash), replay attacks are prevented without extra state beyond a nonce.


3. Minimal working example (Node.js/Express)

Below is a self‑contained server that protects a simple /sum endpoint. The client snippet shows how an agent would add the required header.

3.1 Server (server.js)

// server.js
require('dotenv').config();
const express = require('express');
const { ethers } = require('ethers');
const crypto = require('crypto');

const app = express();
app.use(express.json());

// Configuration – adjust for your environment
const TOKEN_ADDRESS = process.env.USDC_ADDRESS; // e.g. 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 (Base)
const CHAIN_ID = Number(process.env.CHAIN_ID) || 8453; // Base mainnet
const PRICE_PER_CALL = ethers.utils.parseUnits('0.01', 6); // $0.01 USDC (6 decimals)

// Simple in‑memory nonce store (replace with Redis/DynamoDB in prod)
const nonces = new Map();

/**
 * Helper: build the challenge header for a 402 response
 */
function makeChallenge(req) {
  const nonce = ethers.utils.randomBytes(16).toString('hex');
  nonces.set(req.ip, nonce); // naïve per‑IP nonce; improve for scale
  return {
    'X402-Challenge': JSON.stringify({
      price: PRICE_PER_CALL.toString(),
      token: TOKEN_ADDRESS,
      chainId: CHAIN_ID,
      nonce,
    }),
  };
}

/**
 * Middleware that validates the X402-Payment header
 */
function x402Middleware(req, res, next) {
  const auth = req.headers['x402-payment'];
  if (!auth) {
    return res.status(402).set(makeChallenge(req)).json({ error: 'payment required' });
  }

  try {
    const { signer, signature, bodyHash } = JSON.parse(auth);
    const nonce = nonces.get(req.ip);
    if (!nonce) throw new Error('nonce missing or expired');

    // Re‑compute the hash of the request body (empty body => hash of empty string)
    const computedHash = ethers.utils.keccak256(
      ethers.utils.defaultAbiCoder.encode(
        ['bytes'],
        [ethers.utils.keccak256(ethers.utils.toUtf8Bytes(JSON.stringify(req.body)))]
      )
    );
    if (computedHash !== bodyHash) throw new Error('body hash mismatch');

    // Build the signed message
    const message = ethers.utils.solidityKeccak256(
      ['uint256', 'address', 'uint256', 'bytes32', 'bytes32'],
      [
        PRICE_PER_CALL,
        TOKEN_ADDRESS,
        CHAIN_ID,
        ethers.utils.keccak256(ethers.utils.toUtf8Bytes(nonce)),
        bodyHash,
      ]
    );

    const recovered = ethers.utils.recoverAddress(message, signature);
    if (recovered.toLowerCase() !== signer.toLowerCase())
      throw new Error('signature mismatch');

    // Optional: check token balance (requires an RPC call)
    // For demo we assume the signer has funded a escrow contract ahead of time.
    // In production you’d query the ERC20 balance or rely on a paymaster.

    // Consume nonce to prevent replay
    nonces.delete(req.ip);
    next();
  } catch (e) {
    console.warn('x402 validation failed:', e.message);
    return res.status(402).set(makeChallenge(req)).json({ error: 'invalid payment' });
  }
}

// Protected endpoint
app.post('/sum', x402Middleware, (req, res) => {
  const { a, b } = req.body;
  if (typeof a !== 'number' || typeof b !== 'number') {
    return res.status(400).json({ error: 'expected numbers a and b' });
  }
  res.json({ result: a + b });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`x402 demo listening on :${PORT}`));
Enter fullscreen mode Exit fullscreen mode

Explanation of the server code

  • The middleware checks for X402-Payment. If missing, it returns 402 with a challenge that includes price, token, chain ID, and a freshly generated nonce.
  • The client must sign a deterministic message that binds the nonce, price, token, chain ID, and a hash of the request body.
  • Verification recovers the signer address and compares it to the supplied signer.
  • After a successful verification the nonce is removed (preventing replay) and the handler runs.

Trade‑off: Storing nonces in a per‑IP Map works for a demo but does not scale. In production you’d use a shared, low‑latency store (Redis, DynamoDB) with TTL expiration, or rely on a smart‑contract‑based nonce (e.g., ERC‑4337 entry point) to avoid any off‑chain state.

3.2 Client snippet (agent side)


javascript
// agent.js – minimal fetch wrapper that handles 402 challenges
import { ethers } from 'ethers';

const provider = new ethers.JsonRpcProvider(process.env.BASE_RPC); // e.g. https://base.mainnet.rpc.dev
const wallet = new ethers.Wallet(process.env.PRIVATE_KEY, provider);
const USDC = process.env.USDC_ADDRESS; // same as server
const CHAIN_ID = 8453;
const PRICE = ethers.utils.parseUnits('0.01', 6); // must match server

async function signedFetch(url, options = {}) {
  let resp = await fetch(url, options);

  // If we get 402, we need to build and attach a payment proof
  if (resp.status === 402) {
    const challenge = resp.headers.get('x402-challenge');
    if (!challenge) throw new Error('402 without challenge');

    const { price, token, chainId, nonce } = JSON.parse(challenge);
    if (price !== PRICE.toString() || token.toLowerCase() !== USDC.toLowerCase() || chainId !== CHAIN_ID) {
      throw new Error('Challenge parameters mismatch');
    }

    // Hash of the request body (empty for GET, otherwise JSON.stringify)
    const bodyHash = ethers.utils.keccak256(
      ethers.utils.defaultAbiCoder.encode(
        ['bytes'],
        [ethers.utils.keccak256(ethers.utils.toUtf8Bytes(JSON.stringify(options.body || '')))]
      )
    );

    const message = ethers.utils.solidityKeccak256(
      ['uint256', 'address', 'uint256', 'bytes32', 'bytes32'],
      [
        ethers.BigNumber.from(price),
        token,
        chainId,
        ethers.utils.keccak256(ethers.utils.toUtf8Bytes(nonce)),
        bodyHash,
      ]
    );

    const signature = await wallet.signMessage(ethers.utils.arrayify(message));
    const sigObj = ethers.utils.splitSignature(signature);

    const paymentProof = JSON.stringify({
      signer: wallet.address,
      signature: {
        v: sigObj.v,
Enter fullscreen mode Exit fullscreen mode

Top comments (1)

Collapse
 
raknaos profile image
Baptiste Le Bouquin

One thing worth making explicit in a follow-up: the middleware in section 3 verifies a signature but never moves money. The balance check is commented out and no escrow or transfer call happens, so as written the endpoint accepts a proof that cost the caller nothing — fine for illustrating the handshake, but the "Atomic" property in your table only holds if verification includes an actual ERC-20 transfer. That's what the real x402 flow does with EIP-3009 transferWithAuthorization — settlement happens inside the request, so a 402-retry can't be replayed for free.

The nonce store would also work against you in practice: agents behind corporate NAT or a shared egress proxy all present the same IP, so a per-IP nonce serializes or collides them. Returning a per-challenge nonce in the 402 response and echoing it back in the payment header removes the per-IP bookkeeping entirely and lets you expire challenges by timestamp instead of Map deletes.

Curious how you'd handle partial failure after next() — if the handler 500s once the nonce is consumed, the caller has paid and has no receipt to retry with. Is there an intended replay window for the same payment proof?