x402 Explained: HTTP‑Native Micropayments for AI Agents (With Real Code)
TL;DR – The HTTP 402 Payment Required status code lets an AI agent request and receive payment over the same request/response cycle it uses for any other API call. By attaching a signed payment proof to the
402response and having the client resend the request with that proof in anX-Paymentheader, you get a true “pay‑as‑you‑go” mechanism that works without custom SDKs, websockets, or UI pop‑ups. Below is a minimal, production‑ready implementation in TypeScript that shows both sides of the exchange, followed by an honest look at the trade‑offs you’ll face when you adopt it.
1. Why 402 Matters for Agents
Autonomous agents often need to call paid services: LLM inference, data feeds, compute‑heavy plugins, or even other agents. Traditional approaches (API keys, subscription tiers, or off‑chain invoicing) introduce coupling, latency, and operational overhead.
The x402 pattern (named after the HTTP status code) keeps the payment flow inside the HTTP protocol:
- Agent → Service – Normal GET/POST request (no auth needed).
- Service → Agent – If unpaid, returns 402 Payment Required with a JSON body describing the amount, token, chain, and a nonce.
-
Agent → Service – Agent signs a payment proof (e.g., an ERC‑20 transfer approval) and resends the original request with the proof in an
X-Paymentheader. - Service → Agent – Service verifies the proof on‑chain (or via a trusted relayer) and, if valid, processes the request and returns a 200 OK with the payload.
Because the payment proof is just a header, any HTTP client (including fetch, axios, or a raw TCP socket) can participate—no special libraries required beyond a crypto signer for step 3.
2. The Minimal Service (Node.js/Express)
// server.ts
import express, { Request, Response, NextFunction } from 'express';
import { ethers } from 'ethers';
import crypto from 'crypto';
const app = express();
app.use(express.json());
// ---- CONFIG -------------------------------------------------
const USDC_ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; // Base USDC
const CHAIN_ID = 8453; // Base
const PRICE_USDC = ethers.parseUnits('0.05', 6); // $0.05 per call (6 decimals)
const RELAYER_PRIVATE_KEY = process.env.RELAYER_KEY!; // off‑chain signer for receipts
const relayer = new ethers.Wallet(RELAYER_PRIVATE_KEY);
const usdcAbi = [
"function transfer(address to, uint256 amount) returns (bool)",
"function allowance(address owner, address spender) returns (uint256)",
];
// -----------------------------------------------------------
/**
* Verify that the client actually transferred USDC to our treasury.
* In a real deployment you would check the transaction receipt on‑chain.
* For demo purposes we accept a signed message that encodes:
* { amount, nonce, treasury, chainId }
* and verify the signature matches the sender address.
*/
function verifyPayment(header: string): { ok: boolean; sender?: string } {
try {
const { amount, nonce, treasury, chainId, signature } = JSON.parse(
Buffer.from(header, 'base64').toString()
);
if (chainId !== CHAIN_ID || treasury !== process.env.TREASURY!)
return { ok: false };
if (amount !== PRICE_USDC.toString()) return { ok: false };
const msgHash = ethers.hashMessage(
ethers.AbiCoder.defaultAbiCoder().encode(
['uint256', 'address', 'uint256', 'uint256'],
[amount, treasury, nonce, CHAIN_ID]
)
);
const recovered = ethers.recoverAddress(msgHash, signature);
return { ok: recovered.toLowerCase() === process.env.TREASURY!.toLowerCase(), sender: recovered };
} catch {
return { ok: false };
}
}
/**
* Middleware that turns a 402 into a payment challenge.
*/
function requirePayment(req: Request, res: Response, next: NextFunction) {
const payment = req.header('X-Payment');
if (!payment) {
// No payment yet → issue a challenge
const nonce = crypto.randomBytes(8).readUIntBE(0, 6);
const challenge = {
amount: PRICE_USDC.toString(),
token: USDC_ADDRESS,
chainId: CHAIN_ID,
treasury: process.env.TREASURY, // where agent should send USDC
nonce,
};
return res.status(402).json({
error: 'Payment Required',
challenge,
// Hint for clients: encode challenge as base64 JSON and sign with their wallet
hint:
'X-Payment: base64(JSON.stringify({amount, nonce, treasury, chainId, signature}))',
});
}
const { ok, sender } = verifyPayment(payment);
if (!ok) {
return res.status(402).json({ error: 'Invalid payment proof' });
}
// Attach the verified sender for downstream handlers (optional)
(req as any).paidBy = sender;
next();
}
/**
* Example protected endpoint: a dummy LLM wrapper.
*/
app.post('/v1/complete', requirePayment, (req, res) => {
const prompt = req.body.prompt as string;
// …call your model, compute, etc.
const fakeResponse = { completion: `Echo: ${prompt}` };
res.json(fakeResponse);
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`x402 service listening on :${PORT}`));
What the code does
| Step | Detail |
|---|---|
| Challenge generation | When a request arrives without an X-Payment header, the server returns 402 and a JSON challenge containing the exact amount, token address, chain ID, treasury (the address that should receive USDC), and a nonce to prevent replay attacks. |
| Client proof | The client must sign the challenge (amount + nonce + treasury + chainId) with its Ethereum wallet, then Base64‑encode the JSON {amount, nonce, treasury, chainId, signature} and send it as X-Payment. |
| Verification | The server recovers the signer from the signature, checks that it matches the treasury address (i.e., the client actually authorized a transfer to the treasury), validates the amount and nonce, and only then proceeds. |
| Statelessness | No server‑side session is needed; the nonce prevents replay, and the payment proof is self‑contained. |
Note – In production you would replace the simple signature verification with an on‑chain receipt check (e.g., query the USDC
Transferevent from the treasury). The signature approach is fine for low‑value, low‑latency demos and avoids the need for a full node or an indexing service.
3. The Agent Client (TypeScript)
// client.ts
import fetch from 'node-fetch';
import { ethers } from 'ethers';
const SERVICE_URL = 'http://localhost:3000/v1/complete';
const PRIVATE_KEY = process.env.AGENT_KEY!; // agent's EOA
const wallet = new ethers.Wallet(PRIVATE_KEY);
const USDC_ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
const CHAIN_ID = 8453;
const TREASURY = process.env.TREASURY!; // same as server expects
/**
* Generic wrapper that automatically handles 402 challenges.
*/
async function x402Fetch<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
let response = await fetch(input, init);
if (response.status !== 402) {
const data = await response.json();
return data as T;
}
// ----- We got a challenge -----
const challenge = await response.json();
const { amount, token, treasury, nonce, chainId } = challenge.challenge;
// Build the message to sign
const msgHash = ethers.hashMessage(
ethers.AbiCoder.defaultAbiCoder().encode(
['uint256', 'address', 'uint256', 'uint256'],
[amount, treasury, nonce, chainId]
)
);
const signature = await wallet.signMessage(msgHash);
const proofObj = { amount, nonce, treasury, chainId, signature };
const proofHeader = Buffer.from(JSON.stringify(proofObj)).toString('base64');
// Retry the original request with the payment header
const paymentInit = {
...init,
headers: {
...(init?.headers ?? {}),
'X-Payment': proofHeader,
},
};
response = await fetch(input, paymentInit);
if (!response.ok) {
throw new Error(`Payment failed: ${response.status} ${response.statusText}`);
}
return (await response.json()) as T;
}
// Example usage:
(async () => {
const result = await x402Fetch<{ completion: string }>(SERVICE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: 'Explain quantum entanglement in one sentence.' }),
});
console.log(result.completion);
})();
How the client works
- **First
Top comments (0)