How to Test x402 APIs: A Builder's Guide
From unit tests to production: how to test your x402 payment integration without burning real money. Mock mode, testnet, and CI/CD patterns.
You're building an API that accepts x402 payments. How do you test it without burning real money? How do you simulate the payment flow, verify your 402 responses are correct, and make sure everything works before going live?
This guide covers the testing workflow from local development to production.
The Testing Stack
| Stage | Network | Money | Use Case |
|---|---|---|---|
| Unit tests | None | None | Test payment logic in isolation |
| Local dev | Mock | None | Test full flow without network |
| Testnet | Base Sepolia | Fake USDC | End-to-end with real signatures |
| Production | Base Mainnet | Real USDC | Live traffic |
You should be able to develop and test most things without touching real money.
Stage 1: Unit Testing Payment Logic
Before testing the full flow, test your payment validation logic in isolation.
What to Test
// Test: 402 response format
it('returns correct 402 structure', async () => {
const response = await handler({ /* no payment */ });
expect(response.status).toBe(402);
expect(response.headers.get('X-Payment')).toBeDefined();
expect(response.headers.get('X-Payment-Network')).toBe('base');
expect(response.headers.get('X-Payment-Address')).toMatch(/^0x[a-fA-F0-9]{40}$/);
});
// Test: Payment validation
it('accepts valid payment signature', async () => {
const validPayment = createMockPayment({ amount: '0.01', valid: true });
const response = await handler({ payment: validPayment });
expect(response.status).toBe(200);
});
// Test: Rejects invalid payments
it('rejects expired payment', async () => {
const expiredPayment = createMockPayment({
amount: '0.01',
expiry: Date.now() - 60000 // 1 minute ago
});
const response = await handler({ payment: expiredPayment });
expect(response.status).toBe(402);
});
// Test: Rejects wrong amount
it('rejects underpayment', async () => {
const underpayment = createMockPayment({ amount: '0.005' }); // Half price
const response = await handler({ payment: underpayment });
expect(response.status).toBe(402);
});
Mock Payment Helper
function createMockPayment({ amount, valid = true, expiry = Date.now() + 300000 }) {
const payment = {
amount,
currency: 'USDC',
network: 'base',
recipient: process.env.PAYMENT_ADDRESS,
nonce: crypto.randomUUID(),
expiry,
};
if (valid) {
// Sign with test private key
payment.signature = signPayment(payment, TEST_PRIVATE_KEY);
}
return btoa(JSON.stringify(payment));
}
Stage 2: Local Development with Mocks
For local development, you want the full request flow without network calls.
Mock Mode Flag
Add a mock mode to your x402 handler:
// lib/x402.ts
export async function validatePayment(paymentProof: string): Promise<boolean> {
if (process.env.X402_MOCK === 'true') {
// Accept any payment in mock mode
console.log('[x402] Mock mode: accepting payment');
return true;
}
// Real validation...
return await verifyWithFacilitator(paymentProof);
}
Environment Setup
# .env.local
X402_MOCK=true
X402_PRICE_USD=0.01
PAYMENT_ADDRESS=0x0000000000000000000000000000000000000000
Testing with curl
# Step 1: Get 402 response
curl -X POST http://localhost:3000/api/tool \
-H "Content-Type: application/json" \
-d '{"input": "test"}'
# Response: 402 with X-Payment header
# Step 2: Retry with fake payment (mock mode accepts anything)
curl -X POST http://localhost:3000/api/tool \
-H "Content-Type: application/json" \
-H "X-Payment-Proof: fake-proof-for-testing" \
-d '{"input": "test"}'
# Response: 200 with actual result
Stage 3: Testnet Integration
When you need to test real signatures and facilitator integration, use Base Sepolia.
Get Test USDC
- Get Sepolia ETH from a faucet (for gas)
- Get test USDC from Circle's faucet or swap on testnet
Configure for Testnet
# .env.test
X402_MOCK=false
X402_NETWORK=base-sepolia
FACILITATOR_URL=https://testnet.facilitator.x402.org
PAYMENT_ADDRESS=0xYourTestnetAddress
Test Client Setup
// test-client.ts
import { Wallet } from 'ethers';
const TEST_WALLET = new Wallet(process.env.TEST_PRIVATE_KEY);
async function testPayment() {
// 1. Make request, get 402
const response = await fetch('https://your-api.dev/tool', {
method: 'POST',
body: JSON.stringify({ input: 'test' }),
});
if (response.status !== 402) {
throw new Error('Expected 402');
}
// 2. Parse payment requirements
const paymentHeader = response.headers.get('X-Payment');
const payment = JSON.parse(atob(paymentHeader));
// 3. Sign payment
const signature = await TEST_WALLET.signMessage(
JSON.stringify(payment)
);
// 4. Retry with payment
const paidResponse = await fetch('https://your-api.dev/tool', {
method: 'POST',
headers: {
'X-Payment-Proof': btoa(JSON.stringify({ ...payment, signature })),
},
body: JSON.stringify({ input: 'test' }),
});
console.log('Status:', paidResponse.status);
console.log('Result:', await paidResponse.json());
}
What to Verify on Testnet
- 402 response includes all required headers
- Payment signature is validated correctly
- Facilitator accepts the payment
- Your API returns data after payment
- Nonces prevent replay attacks
- Expired payments are rejected
- Wrong amounts are rejected
Stage 4: Production Testing
Before going live, do a small-scale test with real money.
Checklist
- [ ] Payment address is correct (triple check!)
- [ ] Facilitator is configured for mainnet
- [ ] Prices are set correctly
- [ ] Error messages don't leak sensitive info
- [ ] Rate limiting is in place
- [ ] Monitoring/alerting is set up
Smoke Test
Use a real wallet with a small amount of USDC ($1-5) to test the full flow:
# Using Coinbase Agentic Wallet
npx awal pay https://your-api.com/tool \
--body '{"input": "test"}' \
--network base
Verify:
- Payment went through
- API returned correct response
- USDC arrived in your wallet
Common Issues and Fixes
"Invalid signature"
Causes:
- Wrong network (testnet vs mainnet)
- Payment expired before validation
- Nonce already used
- Signature format mismatch
Debug:
// Add logging to your validator
console.log('Payment received:', {
amount: payment.amount,
network: payment.network,
expiry: new Date(payment.expiry),
nonce: payment.nonce,
});
"Facilitator unreachable"
Causes:
- Wrong facilitator URL
- Network issues
- Facilitator rate limiting
Fix:
- Add retry logic with exponential backoff
- Have a fallback facilitator
- Cache validation results briefly
"402 but no X-Payment header"
Cause: Your 402 response is malformed.
Fix:
// Make sure you're setting headers correctly
return new Response(JSON.stringify({ error: 'Payment required' }), {
status: 402,
headers: {
'Content-Type': 'application/json',
'X-Payment': btoa(JSON.stringify(paymentRequest)),
'X-Payment-Network': 'base',
'X-Payment-Address': PAYMENT_ADDRESS,
},
});
Payments accepted but not settled
Cause: Facilitator isn't settling, or you're not checking settlement.
Reality check: Most x402 implementations trust the signature and don't wait for settlement. If you need guaranteed settlement, you'll need to poll the facilitator or watch onchain.
Testing Tools
x402 CLI (if it exists)
# Test your endpoint
x402 test https://your-api.com/tool --payload '{"input": "test"}'
# Verify 402 response format
x402 lint https://your-api.com/tool
Custom Test Script
// scripts/test-x402.ts
import { x402Client } from '@x402/client';
const client = new x402Client({
wallet: process.env.TEST_WALLET,
network: 'base-sepolia',
});
async function runTests() {
console.log('Testing 402 response...');
const { status, headers } = await client.probe('https://your-api.dev/tool');
assert(status === 402, 'Should return 402');
assert(headers['x-payment'], 'Should have X-Payment header');
console.log('Testing paid request...');
const result = await client.request('https://your-api.dev/tool', {
method: 'POST',
body: { input: 'test' },
});
assert(result.status === 200, 'Should return 200 after payment');
console.log('Testing replay protection...');
// Try to reuse the same payment...
console.log('All tests passed!');
}
CI/CD Integration
GitHub Actions Example
name: x402 Integration Tests
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Start test server
run: npm run dev &
- name: Wait for server
run: npx wait-on http://localhost:3000
- name: Run x402 tests (mock mode)
env:
X402_MOCK: true
run: npm run test:x402
- name: Run x402 tests (testnet)
if: github.ref == 'refs/heads/main'
env:
X402_MOCK: false
X402_NETWORK: base-sepolia
TEST_PRIVATE_KEY: ${{ secrets.TEST_WALLET_KEY }}
run: npm run test:x402:testnet
Summary
| Stage | Command | What You're Testing |
|---|---|---|
| Unit | npm test | Payment logic in isolation |
| Local | curl with mock mode | Full flow without network |
| Testnet | Test script + Sepolia | Real signatures, facilitator |
| Production | Smoke test with $1 | Everything for real |
Key principle: Most of your testing should happen in mock mode. Only go to testnet when you need to verify real cryptographic flows. Only touch mainnet for final validation.
Resources
- How x402 Works — understand the protocol
- Setting Up an x402 Wallet — for testing clients
- Base Sepolia Faucet — get test ETH
Questions? Email silas@agentutil.dev