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 that need to call paid services without wrapping every request in a custom billing layer.


Why HTTP‑Native Micropayments Matter for Agents

AI agents frequently need to fetch data, run inference, or invoke third‑party tools. Traditional payment flows—API keys, invoices, or subscription portals—require out‑of‑band negotiation, manual token management, or heavyweight SDKs. For fully autonomous agents, those steps break the “stateless request → response” contract that makes HTTP easy to compose, retry, and cache.

The x402 specification (defined in the ERC‑4337‑compatible “Pay‑Per‑Use” extension) turns HTTP itself into a payment channel:

  1. Client sends a normal GET/POST to a resource URL.
  2. If the resource requires payment, the server replies with 402 Payment Required and a structured Pay-Payload header.
  3. The client extracts the payment request, signs it with its wallet, and resends the request with an X-Payment header containing the signed proof.
  4. The server verifies the proof, credits the payer, and returns the actual 200 OK response.

Because the payment proof lives in an HTTP header, any HTTP client (fetch, axios, curl, language‑specific HTTP libraries) can participate without changing the endpoint URL or adding a SDK‑specific wrapper. The flow is stateless from the server’s perspective: each request carries everything needed to validate payment.


Core x402 Message Flow

1. Payment Request (Server → Client)

When a protected resource detects missing or insufficient payment, it returns:

HTTP/1.1 402 Payment Required
Content-Type: application/json
Pay-Payload: {
  "scheme":"erc20",
  "network":"base",
  "token":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC on Base
  "amount":"1000000", // 1 USDC (6 decimals)
  "payload":"<base64url-encoded JSON>",
  "maxFeePerGas":"2000000000",
  "maxPriorityFeePerGas":"1000000000"
}
Enter fullscreen mode Exit fullscreen mode

payload encodes the exact request the client wishes to make (method, URI, body hash, nonce). The server signs this payload with its EIP‑712 domain separator to prevent replay attacks.

2. Payment Proof (Client → Server)

The client’s wallet builds an ERC‑4337 UserOperation that pays the specified amount to the server’s beneficiary address. After the operation is included in a block, the client extracts the paymasterAndData field (or the simple ERC‑20 transfer receipt) and bases64‑encodes it:

X-Payment: <base64url(UserOperation)> 
Enter fullscreen mode Exit fullscreen mode

The client then retries the original request, adding this header.

3. Verification (Server)

The server:

  1. Decodes the X-Payment header.
  2. Checks that the UserOperation pays at least the requested amount to the correct beneficiary on the correct chain.
  3. Verifies the replay‑protected nonce (included in the original Pay-Payload).
  4. If all checks pass, increments its internal accounting and returns 200 OK with the resource.

Because the proof is a standard Ethereum transaction, verification can be done with any EVM‑compatible library (ethers.js, viem, web3.py).


Honest Trade‑offs

Aspect Benefit Cost / Limitation
Statelessness No server‑side session needed; easy to scale horizontally. Requires the client to retain the original request details (URI, method, body) to reconstruct the payment payload.
Latency Payment verification is a single on‑chain lookup; usually < 2 s on Base. Adds at least one round‑trip (402 → retry) plus the time for the UserOperation to be mined. For sub‑second latency services, this may be unacceptable.
Token Flexibility Works with any ERC‑20 (including USDC, DAI, or custom tokens) on any EVM L2. The server must hold the token contract address and decimals; agents need a wallet funded with that token on the specific chain.
Replay Protection Nonce + chain‑specific domain prevents reuse of a payment proof. Server must store nonces (or a short‑lived cache) until they expire; this introduces a small state requirement.
Developer Experience Uses standard HTTP headers; no new SDK required for the core flow. Agents must implement ERC‑4337 UserOperation construction (or rely on a paymaster) – a non‑trivial step for teams unfamiliar with account abstraction.
Cost Overhead Gas costs are borne by the payer; the server only pays for verification (cheap). If the agent calls many micro‑endpoints, the cumulative gas cost can exceed the service price, especially on L1. Using an L2 like Base mitigates this.

In practice, x402 shines when:

  • Individual calls are inexpensive (≤ $0.10) but frequent enough that manual API‑key rotation is tedious.
  • The agent already holds a wallet on an L2 for other reasons (e.g., interacting with DeFi).
  • The service provider can afford to run a simple verification endpoint.

If you need sub‑100 ms latency, prepaid credits or off‑chain escrow may be preferable.


Minimal Working Example

Below is a Node.js/Express server that protects a /echo endpoint with x402, and a client that pays using viem (a lightweight EVM library). The code assumes you have a funded wallet on Base (USDC contract 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913).

Server (server.js)


js
// server.js
import express from 'express';
import { ethers } from 'ethers';
import crypto from 'crypto';
import { keccak256, toUtf8Bytes, defaultAbiCoder } from 'viem';

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

const USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
const BENEFICIARY = '0xYourServerAddress'; // replace with your EOA or contract
const CHAIN_ID = 8453; // Base
const NONCE_TTL_MS = 5 * 60 * 1000; // 5 minutes

// In‑memory nonce store (for demo only; use Redis in prod)
const nonces = new Map();

function generateNonce() {
  const nonce = ethers.randomBytes(8).toString('hex');
  nonces.set(nonce, Date.now());
  return nonce;
}
function cleanNonces() {
  const now = Date.now();
  for (const [nonce, ts] of nonces.entries()) {
    if (now - ts > NONCE_TTL_MS) nonces.delete(nonce);
  }
}
setInterval(cleanNonces, 60_000);

// Helper to build the Pay-Payload
function buildPayPayload(uri, method, bodyHash) {
  const nonce = generateNonce();
  const payload = {
    scheme: 'erc20',
    network: 'base',
    token: USDC,
    amount: '1000000', // 1 USDC (6 decimals)
    payload: Buffer.from(
      JSON.stringify({ uri, method, bodyHash, nonce })
    ).toString('base64url'),
    maxFeePerGas: '2000000000',
    maxPriorityFeePerGas: '1000000000',
  };
  return JSON.stringify(payload);
}

// Middleware to verify X-Payment
async function verifyPayment(req, res, next) {
  const auth = req.headers['x-payment'];
  if (!auth) return res.status(401).send('Missing X-Payment');
  const uoJson = Buffer.from(auth, 'base64url').toString();
  const uo = JSON.parse(uoJson);

  // Very simple check: ensure a transfer to BENEFICIARY of at least amount
  // In prod, use EntryPoint's simulateValidation or a paymaster.
  if (uo.target.toLowerCase() !== BENEFICIARY.toLowerCase()) {
    return res.status(402).send('Incorrect beneficiary');
  }
  // Assume the call data is a plain ERC20 transfer: function transfer(address,uint256)
  if (uo.data.slice(0, 10) !== '0xa9059cbb') {
    return res.status(402).send('Unsupported call data');
  }
  const [to, value] = defaultAbiCoder.decode(
    ['address', 'uint256'],
    '0x' + uo.data.slice(10)
  );
  if (to.toLowerCase() !== BENEFICIARY.toLowerCase()) {
    return res.status(402).send('Transfer not to beneficiary');
  }
  const amountSent = Number(value);
  const required = 1_000_000; // 1 USDC
  if (amountSent < required) {
    return res.status(402).send('
Enter fullscreen mode Exit fullscreen mode

Top comments (0)