x402 Explained: HTTP‑Native Micropayments for AI Agents (With Real Code)
Target audience: developers who are building autonomous AI agents and need a lightweight, standards‑based way to charge per‑call.
Why x402 matters for agents
Autonomous agents often need to consume external services—LLM APIs, data feeds, compute‑heavy tools—without a human in the loop to approve each transaction. Traditional API keys or subscription models either grant unlimited access (risk of runaway cost) or require manual billing cycles that break autonomy.
The x402 pattern re‑uses the existing HTTP status code 402 Payment Required to turn every request into a self‑contained payment negotiation. The flow is:
- Client makes a normal GET/POST to a protected endpoint.
-
Server responds
402with aPayment-Requestheader that encodes:- the amount (in a stablecoin),
- the payee address,
- a nonce / expiry to prevent replay, and
- an optional
Payment-Optionsheader describing supported networks.
-
Client (the agent) signs a transaction that satisfies the request, attaches the signed payload in a
Paymentheader, and retries the request. -
Server validates the signature, checks the nonce/expiry, and if everything is correct, returns
200plus the desired resource.
Because the negotiation lives entirely in HTTP headers, no new transport protocol, websocket, or custom SDK is required—any HTTP client that can add headers and sign a transaction works.
Core components of an x402‑enabled service
| Component | Responsibility | Typical tech |
|---|---|---|
| Resource endpoint | Returns data or performs work only after a valid payment. | Express / FastAPI / Cloudflare Workers |
| Payment verifier | Checks Payment-Request → validates signature, amount, nonce, expiry. |
ethers.js / web3.py + EIP‑712 typed data |
| Nonce store | Prevents replay attacks (simple in‑memory set or Redis). | Redis, KV store |
| Funding flow (optional) | Allows the service to pull earned USDC to a treasury. |
erc20.approve + erc20.transferFrom (requires agent approval) |
Honest trade‑offs
| Trade‑off | Description |
|---|---|
| Latency | Each paid request adds one round‑trip for the 402 challenge and a second for the signed retry. Expect ~150‑300 ms extra on Base (≈2 s block time) plus network overhead. |
| Wallet management | Agents must hold a private key (or use a custodial signer) and have enough USDC to cover calls. Key rotation and secure storage become part of the agent lifecycle. |
| User experience | If the agent runs on behalf of a human user, you still need a way to fund the agent’s wallet (e.g., a fiat‑on‑ramp or a parent wallet). |
| Regulatory | Treating each call as a financial transaction may trigger money‑transmitter considerations in some jurisdictions; consult counsel if you plan to scale. |
| Volatility mitigation | Using a stablecoin (USDC) removes price swings, but you still depend on the issuing circle’s solvency and the Base chain’s availability. |
| Development overhead | You need to implement the verifier and nonce store; there is no “drop‑in” library yet (though the spec is simple enough to copy). |
For many agent‑to‑agent micro‑services (e.g., paying $0.01 for a sentiment‑score API), the extra latency and complexity are acceptable compared with building a bespoke billing system.
Minimal working example (Node.js/TypeScript)
Below is a complete, runnable snippet that shows both the server side (Express) and a client agent that pays with USDC on Base. It assumes you have:
- Node ≥ 18
-
dotenvfor a.envfile containingPRIVATE_KEY(agent’s wallet) andRECIPIENT_ADDRESS(service’s USDC wallet) -
ethers@6installed
npm init -y
npm install express ethers dotenv
.env
PRIVATE_KEY=0xYOUR_AGENT_PRIVATE_KEY
RECIPIENT_ADDRESS=0xSERVICE_USDC_WALLET
BASE_RPC=https://base.mainnet.rpc.dev
USDC_ADDRESS=0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 # USDC on Base
server.ts (the protected resource)
ts
import express from 'express';
import { ethers } from 'ethers';
import crypto from 'crypto';
import dotenv from 'dotenv';
dotenv.config();
const app = express();
const PORT = 3000;
// USDC contract minimal ABI (balanceOf not needed here)
const usdcAbi = [
'function approve(address spender, uint256 amount) external returns (bool)',
'function transferFrom(address src, address dst, uint256 amount) external returns (bool)',
];
const provider = new ethers.JsonRpcProvider(process.env.BASE_RPC!);
const usdc = new ethers.Contract(
process.env.USDC_ADDRESS!,
usdcAbi,
provider
);
// In‑memory nonce store (use Redis in production)
const usedNonces = new Set<string>();
/**
* Generate a payment challenge.
* Returns a base64url‑encoded JSON with: amount, payee, nonce, expiry, chainId.
*/
function makeChallenge(): string {
const amount = ethers.parseUnits('0.05', 6); // $0.05 USDC (6 decimals)
const nonce = crypto.randomBytes(8).toString('hex');
const expiry = Math.floor(Date.now() / 1000) + 60; // 60‑second window
const payload = {
amount: amount.toString(),
payee: process.env.RECIPIENT_ADDRESS,
nonce,
expiry,
chainId: 8453, // Base
};
// Encode as base64url (safe for HTTP headers)
return Buffer.from(JSON.stringify(payload)).toString('base64url');
}
/**
* Verify a signed payment.
* Expects header: Payment: <base64url(signature)>
*/
function verifyPayment(challenge: string, signatureB64: string): boolean {
const payload = JSON.parse(Buffer.from(challenge, 'base64url').toString());
const { amount, payee, nonce, expiry, chainId } = payload;
// Replay protection
if (usedNonces.has(nonce)) return false;
usedNonces.add(nonce);
// Optional: clean old nonces periodically
// Expiry check
if (Math.floor(Date.now() / 1000) > expiry) return false;
// Re‑create the typed-data structure (EIP‑712) that the agent signed.
// Here we simplify: agent signs keccak256(amount||payee||nonce||expiry||chainId)
const domain = {
name: 'USDC',
version: '1',
chainId,
verifyingContract: process.env.USDC_ADDRESS,
};
const types = {
Payment: [
{ name: 'amount', type: 'uint256' },
{ name: 'payee', type: 'address' },
{ name: 'nonce', type: 'bytes32' },
{ name: 'expiry', type: 'uint256' },
{ name: 'chainId', type: 'uint256' },
],
};
const value = {
amount: BigInt(amount),
payee: payee as `0x${string}`,
nonce: ethers.zeroPadValue(nonce, 32) as `0x${string}`,
expiry: BigInt(expiry),
chainId: BigInt(chainId),
};
const signingKey = new ethers.Wallet(process.env.PRIVATE_KEY!, provider);
const recovered = ethers.verifyTypedData(
domain,
types,
value,
ethers.joinSignature(signatureB64) // expects compact 65‑byte signature
);
return recovered.toLowerCase() === signingKey.address.toLowerCase();
}
/**
* Middleware that enforces x402.
*/
async function x402Middleware(req, res, next) {
const auth = req.headers['payment'];
if (!auth) {
// First request: issue challenge
const challenge = makeChallenge();
res.set('Payment-Request', challenge);
res.set('Payment-Options', 'USDC on Base');
return res.status(402).send('Payment required');
}
// Retrieve the challenge we previously sent (in a real service you’d store it per‑IP or per‑session)
// For demo we re‑generate; in production bind challenge to a cookie or JWT.
const challenge = makeChallenge(); // <-- simplified
if (!verifyPayment(challenge, auth as string)) {
return res.status(402).send('Invalid payment');
}
// Optional: actually pull the funds (requires agent to have approved USDC transferFrom)
Top comments (0)