x402 Explained: HTTP-Native Micropayments for AI Agents (With Real Code)
Target audience: developers building autonomous AI agents who need a low‑overhead way to charge for API usage without reinventing payment plumbing.
1. Why x402 matters for AI agents
AI agents today often expose capabilities as HTTP endpoints (REST, GraphQL, or even WebSocket‑wrapped RPC). When an agent wants to sell a service—e.g., a summarization model, a translation API, or a tool‑call wrapper—it must handle three concerns:
- Identity – Who is calling?
- Authorization – Is the caller allowed to use the endpoint?
- Settlement – How does the caller pay for each invocation?
Traditional solutions bolt on OAuth scopes, API‑key databases, and separate billing services. Each adds latency, state, and operational overhead. x402 flips the model: payment becomes part of the HTTP request/response cycle itself, using a well‑defined status code (402 Payment Required) and a standardized payload format. The agent can remain stateless; the client handles the payment flow, and the server only needs to verify a cryptographic proof.
Core properties
| Property | How x402 achieves it | Practical implication |
|---|---|---|
| Stateless server | No per‑user balances stored; validation relies on a signed payment receipt. | Simpler deployment, horizontal scaling works out‑of‑the‑box. |
| Atomic per‑call payment | Each request must carry a fresh proof; no batching or credit needed. | Prevents abuse via replay; fine‑grained pricing (e.g., $0.001 per call). |
| Chain‑agnostic | The spec only requires a verifiable signature; any EVM‑compatible chain (or even non‑EVM with adapters) works. | You can pick USDC on Base, Polygon, or a testnet for experimentation. |
| HTTP‑native | Uses existing status codes and headers; no new transport protocol. | Works with any HTTP client, middleware, API gateway, or service mesh. |
2. The x402 flow in a nutshell
-
Client → Server (initial request)
- No payment proof attached.
- Server responds with
402 Payment Requiredand aPayloadheader containing:-
amount(in smallest token unit, e.g., wei for USDC 6‑decimals) -
asset(contract address of the token) -
network(chain ID) -
payload(a nonce or arbitrary data the server wants signed) -
maxTimestamp(expiry for the proof)
-
-
Client
- Constructs an EIP‑712 typed data structure matching the server’s fields.
- Signs it with the payer’s private key (usually an externally‑owned account or a smart‑wallet).
- Sends a second request with the same headers plus an
X-Paymentheader containing the signature (r, s, v) and the payer address.
-
Server
- Verifies the signature against the expected typed data.
- Checks that the nonce hasn’t been used (optional replay protection).
- If valid, returns
200 OKand the actual response body. - If invalid or expired, returns another
402(or400 Bad Requestfor malformed signatures).
Because the verification is pure cryptography, the server does not need to query a blockchain node for each request (unless you want on‑chain settlement later). The payment can be settled off‑chain via a custodial service, or the signatures can be batched and submitted to a rollup for eventual on‑chain clearing.
3. Minimal working example (Node.js + Express)
Below is a self‑contained snippet that implements the server side of x402 for a simple “echo” endpoint priced at 0.001 USDC on Base (chain ID 8453). The client side uses ethers.js to build and sign the EIP‑712 payload.
Note: This code is deliberately stripped down for clarity. Production use should add:
- Rate limiting per payer address
- Persistent nonce store (Redis, DB) to prevent replay
- Proper error handling and logging
- TLS termination at the edge
3.1 Server (server.js)
// server.js
const express = require('express');
const { ethers } = require('ethers');
const app = express();
app.use(express.json());
// Configuration – adjust for your token & price
const USDC_ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; // Base USDC
const CHAIN_ID = 8453; // Base mainnet
const PRICE_WEI = ethers.parseUnits('0.001', 6); // 0.001 USDC (6 decimals)
// In‑memory nonce set (demo only)
const usedNonces = new Set();
// Helper: build the EIP‑712 domain
function domain() {
return {
name: 'x402 Echo Service',
version: '1',
verifyingContract: USDC_ADDRESS,
chainId: CHAIN_ID,
};
}
// Helper: build the message (the part the client signs)
function message(nonce, maxTimestamp) {
return {
amount: PRICE_WEI.toString(),
asset: USDC_ADDRESS,
network: String(CHAIN_ID),
payload: nonce,
maxTimestamp: maxTimestamp.toString(),
};
}
// Middleware that enforces x402
async function requirePayment(req, res, next) {
const sigHeader = req.headers['x-payment'];
if (!sigHeader) {
// First challenge – ask for payment
const nonce = ethers.randomBytes(32).toString('hex');
const maxTimestamp = Math.floor(Date.now() / 1000) + 60; // 1‑minute window
res.set({
'Payload': JSON.stringify({
amount: PRICE_WEI.toString(),
asset: USDC_ADDRESS,
network: String(CHAIN_ID),
payload: nonce,
maxTimestamp,
}),
});
return res.status(402).send('Payment required');
}
// Verify signature
try {
const { r, s, v, payer } = JSON.parse(sigHeader);
// Recover nonce & maxTimestamp from the request body (they were echoed)
const { payload: nonce, maxTimestamp } = req.body;
if (!nonce || !maxTimestamp) throw new Error('Missing payload');
// Replay check
if (usedNonces.has(nonce)) throw new Error('Nonce already used');
usedNonces.add(nonce);
const typedData = {
types: {
EIP712Domain: [
{ name: 'name', type: 'string' },
{ name: 'version', type: 'string' },
{ name: 'verifyingContract', type: 'address' },
{ name: 'chainId', type: 'uint256' },
],
Payment: [
{ name: 'amount', type: 'uint256' },
{ name: 'asset', type: 'address' },
{ name: 'network', type: 'uint256' },
{ name: 'payload', type: 'bytes32' },
{ name: 'maxTimestamp', type: 'uint256' },
],
},
domain: domain(),
primaryType: 'Payment',
message: {
amount: PRICE_WEI.toString(),
asset: USDC_ADDRESS,
network: CHAIN_ID,
payload: nonce,
maxTimestamp: parseInt(maxTimestamp, 10),
},
};
const recovered = ethers.verifyTypedData(typedData.domain, typedData.types, typedData.message, { r, s, v });
if (ethers.getAddress(recovered) !== ethers.getAddress(payer)) {
throw new Error('Signature mismatch');
}
// Payment OK – fall through to handler
next();
} catch (err) {
console.error('x402 verification failed:', err.message);
res.status(400).send('Invalid payment proof');
}
}
// Example endpoint: echo back the JSON body
app.post('/echo', requirePayment, (req, res) => {
res.json({ echo: req.body, receivedAt: new Date().toISOString() });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`x402 echo service listening on :${PORT}`));
3.2 Client (client.js)
js
// client.js
const { ethers } = require('ethers');
const fetch = require('node-fetch');
// Setup – replace with your own private key (NEVER commit this)
const PRIVATE_KEY = '0xYOUR_PRIVATE_KEY';
const provider = new ethers.JsonRpcProvider('https://base.mainnet.rpc.url'); // any Base RPC
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
const SERVICE_URL = 'http://localhost:3000/echo';
const PAYLOAD = { msg: 'hello from agent' };
async function fetchWithPayment() {
// Step 1: get challenge
let resp = await fetch(SERVICE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(PAYLOAD),
});
if (resp.status !== 402) {
throw new Error(`Unexpected status: ${resp.status}`);
}
const payloadHeader = resp.headers.get('Payload');
if (!payloadHeader) throw new Error('Missing Payload header');
const { amount, asset, network, payload, maxTimestamp } = JSON.parse(payloadHeader);
// Step 2: sign the typed data
const domain = {
name: 'x402 Echo Service',
version: '1',
verifyingContract: asset,
chainId: parseInt(network, 10),
};
const types = {
Payment: [
{ name: 'amount', type: 'uint256' },
{ name: 'asset', type: 'address' },
{ name: 'network', type: 'uint256' },
{ name: 'payload', type: 'bytes32' },
{ name: 'maxTimestamp', type: 'uint256' },
],
};
const message = {
amount,
asset,
network
Top comments (0)