Skip to main content

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 address
  • asset: Token contract address (USDC on Base)
  • maxTimeoutSeconds: How long the payment authorization is valid

Client Implementation

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

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"

  1. Check wallet balance on BaseScan
  2. Bridge USDC from Ethereum if needed
  3. For testnet: get USDC from Coinbase faucet

"Payment verification failed"

  1. Ensure you're on the correct network (Base mainnet vs Sepolia)
  2. Check that Permit2 is approved for USDC spending
  3. Verify the payment hasn't expired

"X-PAYMENT header is missing"

  1. Check that your client is using x402-fetch or manually adding the header
  2. Verify the initial 402 response was received
  3. Ensure the payment was signed correctly

"Nonce already used"

  1. Each payment can only be used once
  2. Generate a new nonce for each request
  3. Don't retry with the same signed payment

Additional Resources


Questions? Join our Discord or open an issue