x402 Deep Dive
A comprehensive guide to implementing x402 payments, from basic concepts to production deployment.
What You'll Learn
- How x402 works under the hood
- Client-side payment handling
- Server-side payment verification
- Error handling and edge cases
- Production best practices
How x402 Works
The 402 Status Code
HTTP 402 "Payment Required" was reserved in 1999 for future use. The x402 protocol finally puts it to work:
Client Server
| |
| POST /api/endpoint |
|------------------------------>|
| |
| 402 Payment Required |
| + Payment instructions |
|<------------------------------|
| |
| Sign payment (wallet) |
| |
| POST /api/endpoint |
| + X-PAYMENT header |
|------------------------------>|
| |
| Verify payment, execute |
| |
| 200 OK + response |
|<------------------------------|
The 402 Response
When a server requires payment, it returns:
HTTP/1.1 402 Payment Required
Content-Type: application/json
{
"x402Version": 1,
"accepts": [{
"scheme": "exact",
"network": "base",
"maxAmountRequired": "10000",
"resource": "https://api.example.com/endpoint",
"payTo": "0x742d35Cc6634C0532925a3b844Bc9e7595f...",
"maxTimeoutSeconds": 300,
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"extra": {
"name": "API Call",
"description": "Payment for API access"
}
}],
"error": "X-PAYMENT header is missing"
}
Key fields:
maxAmountRequired: Amount in smallest unit (USDC has 6 decimals, so 10000 = $0.01)payTo: Recipient wallet addressasset: Token contract address (USDC on Base)maxTimeoutSeconds: How long the payment authorization is valid
Client Implementation
Using x402-fetch (Recommended)
The easiest approach is to wrap fetch with automatic payment handling:
import { wrapFetchWithPayment } from 'x402-fetch';
import { createWalletClient, http } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { base } from 'viem/chains';
// Create a wallet client
const wallet = createWalletClient({
account: privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`),
chain: base,
transport: http(),
});
// Wrap fetch - payments happen automatically on 402
const x402Fetch = wrapFetchWithPayment(wallet);
// Use like regular fetch
const response = await x402Fetch('https://api.example.com/endpoint', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: 'your payload' }),
});
Manual Payment Handling
For more control, handle 402 responses manually:
import { signPayment } from 'x402-fetch';
async function callPaidAPI(url: string, options: RequestInit, wallet: WalletClient) {
// First request (will get 402)
const initialResponse = await fetch(url, options);
if (initialResponse.status !== 402) {
return initialResponse;
}
// Parse payment requirements
const paymentInfo = await initialResponse.json();
const paymentOption = paymentInfo.accepts[0];
// Check balance before signing
const balance = await getUSDCBalance(wallet.account.address);
const required = BigInt(paymentOption.maxAmountRequired);
if (balance < required) {
throw new Error(`Insufficient USDC: have ${balance}, need ${required}`);
}
// Sign the payment
const payment = await signPayment(wallet, paymentOption);
// Retry with payment header
const paidResponse = await fetch(url, {
...options,
headers: {
...options.headers,
'X-PAYMENT': payment,
},
});
return paidResponse;
}
Payment Header Format
The X-PAYMENT header contains a signed EIP-712 message:
X-PAYMENT: base64(JSON.stringify({
signature: "0x...",
payload: {
scheme: "exact",
network: "base",
amount: "10000",
asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f...",
validUntil: 1703275500,
nonce: "abc123"
}
}))
Server Implementation
Using x402-hono (Recommended)
For Hono-based servers (like nullpath):
import { Hono } from 'hono';
import { x402 } from 'x402-hono';
const app = new Hono();
// Add x402 middleware
app.use('/api/*', x402({
facilitatorUrl: 'https://facilitator.x402.org',
payTo: process.env.WALLET_ADDRESS,
network: 'base',
getPrice: async (c) => {
// Dynamic pricing based on request
const body = await c.req.json();
return body.premium ? 50000 : 10000; // $0.05 or $0.01
},
}));
// Your API route - payment already verified!
app.post('/api/execute', async (c) => {
const body = await c.req.json();
const result = await processRequest(body);
return c.json({ success: true, data: result });
});
Manual Verification
For other frameworks:
import { verifyPayment } from 'x402-server';
async function handleRequest(req: Request): Promise<Response> {
const paymentHeader = req.headers.get('X-PAYMENT');
// No payment - return 402
if (!paymentHeader) {
return new Response(JSON.stringify({
x402Version: 1,
accepts: [{
scheme: 'exact',
network: 'base',
maxAmountRequired: '10000',
resource: req.url,
payTo: process.env.WALLET_ADDRESS,
maxTimeoutSeconds: 300,
asset: USDC_ADDRESS,
}],
error: 'X-PAYMENT header is missing',
}), {
status: 402,
headers: { 'Content-Type': 'application/json' },
});
}
// Verify payment
const verification = await verifyPayment(paymentHeader, {
expectedAmount: 10000,
expectedRecipient: process.env.WALLET_ADDRESS,
facilitatorUrl: 'https://facilitator.x402.org',
});
if (!verification.valid) {
return new Response(JSON.stringify({
error: 'Payment verification failed',
reason: verification.error,
}), { status: 402 });
}
// Process the request
const result = await processRequest(req);
return new Response(JSON.stringify(result), {
headers: { 'Content-Type': 'application/json' },
});
}
Error Handling
Common Client Errors
try {
const response = await x402Fetch(url, options);
const data = await response.json();
} catch (error) {
if (error.code === 'INSUFFICIENT_FUNDS') {
console.error('Not enough USDC in wallet');
// Prompt user to add funds
} else if (error.code === 'PAYMENT_EXPIRED') {
console.error('Payment authorization expired');
// Retry the request
} else if (error.code === 'SIGNATURE_INVALID') {
console.error('Wallet signature failed');
// Check wallet connection
}
}
Server Error Responses
Return helpful error messages:
// Insufficient payment
{
"error": "Payment amount insufficient",
"required": "20000",
"received": "10000",
"x402Version": 1,
"accepts": [/* updated requirements */]
}
// Payment already used
{
"error": "Payment nonce already used",
"code": "DUPLICATE_PAYMENT"
}
// Payment expired
{
"error": "Payment authorization expired",
"expiredAt": "2024-01-15T10:30:00Z",
"code": "PAYMENT_EXPIRED"
}
Production Checklist
Security
- Never log private keys - Use environment variables
- Validate payment amounts - Don't trust client-provided amounts
- Check payment expiry - Reject expired authorizations
- Track nonces - Prevent replay attacks
- Use HTTPS - Always in production
Performance
- Cache payment verification - Store verified payments briefly
- Set reasonable timeouts - 300 seconds is typical
- Handle network failures - Retry logic for blockchain calls
Monitoring
- Log all payments - For accounting and debugging
- Alert on failures - Payment verification errors
- Track revenue - Monitor payment success rates
Troubleshooting
"Insufficient USDC balance"
- Check wallet balance on BaseScan
- Bridge USDC from Ethereum if needed
- For testnet: get USDC from Coinbase faucet
"Payment verification failed"
- Ensure you're on the correct network (Base mainnet vs Sepolia)
- Check that Permit2 is approved for USDC spending
- Verify the payment hasn't expired
"X-PAYMENT header is missing"
- Check that your client is using x402-fetch or manually adding the header
- Verify the initial 402 response was received
- Ensure the payment was signed correctly
"Nonce already used"
- Each payment can only be used once
- Generate a new nonce for each request
- Don't retry with the same signed payment
Additional Resources
- x402 Protocol Specification - Official spec
- x402-fetch npm - Client library
- x402-hono npm - Server middleware
- Quick Start Guide - Get started in 5 minutes
Questions? Join our Discord or open an issue