Testing x402 on Base Sepolia: A Complete Guide
Validate your x402 API with real signatures and fake money. Get test tokens, configure testnet, and run the full payment flow without financial risk.
You've built an API that accepts x402 payments. Before you go live with real money, you need to validate everything works. That's what testnet is for — real cryptographic signatures, real network calls, fake money.
This guide shows you how to set up testnet testing for your x402 API using Base Sepolia.
Why Testnet?
Mock mode is great for development, but it doesn't catch:
- Signature verification bugs
- Facilitator integration issues
- Network-specific problems
- Payment timing edge cases
Testnet gives you the real payment flow without financial risk.
The Setup
| Component | Mainnet | Testnet |
|---|---|---|
| Network | Base | Base Sepolia |
| Chain ID | 8453 | 84532 |
| USDC | Real | Test tokens |
| Facilitator | Production | Testnet instance |
| Risk | Your money | None |
Step 1: Get Test ETH
You need Sepolia ETH for gas fees. Get some from a faucet:
Alchemy Faucet (recommended):
- Go to alchemy.com/faucets/base-sepolia
- Enter your wallet address
- Get 0.1 ETH (enough for thousands of transactions)
Alternative faucets:
Step 2: Get Test USDC
Test USDC isn't as widely available as test ETH. A few options:
Option A: Circle's Test USDC
Circle provides test USDC on various testnets:
# Circle's Base Sepolia USDC contract
0x036CbD53842c5426634e7929541eC2318f3dCF7eYou can get test USDC from Circle's developer faucet if available, or swap test ETH for it on a testnet DEX.
Option B: Deploy Your Own Test Token
For full control, deploy a simple ERC-20:
// TestUSDC.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract TestUSDC is ERC20 {
constructor() ERC20("Test USDC", "tUSDC") {
_mint(msg.sender, 1000000 * 10**6); // 1M USDC (6 decimals)
}
function decimals() public pure override returns (uint8) {
return 6;
}
// Anyone can mint for testing
function mint(address to, uint256 amount) public {
_mint(to, amount);
}
}Deploy with Foundry or Hardhat, then mint tokens to your test wallets.
Option C: Use a Testnet DEX
Swap your test ETH for test USDC on Uniswap (Base Sepolia) or similar.
Step 3: Configure Your API for Testnet
Update your environment variables:
# .env.testnet
X402_NETWORK=base-sepolia
X402_CHAIN_ID=84532
X402_FACILITATOR_URL=https://testnet.facilitator.x402.org
# Your testnet payment address
PAYMENT_ADDRESS=0xYourTestnetAddress
# Price (same as production or lower for testing)
X402_PRICE_USD=0.01Update your x402 handler to check the network:
// lib/x402.ts
export function getPaymentConfig() {
const isTestnet = process.env.X402_NETWORK === 'base-sepolia';
return {
network: isTestnet ? 'base-sepolia' : 'base',
chainId: isTestnet ? 84532 : 8453,
facilitatorUrl: process.env.X402_FACILITATOR_URL,
paymentAddress: process.env.PAYMENT_ADDRESS,
};
}
export function generate402Response(price: number) {
const config = getPaymentConfig();
const paymentRequest = {
amount: price.toString(),
currency: 'USDC',
network: config.network,
chainId: config.chainId,
recipient: config.paymentAddress,
nonce: crypto.randomUUID(),
expiry: Date.now() + 300000, // 5 minutes
};
return new Response(JSON.stringify({ error: 'Payment required', price }), {
status: 402,
headers: {
'Content-Type': 'application/json',
'X-Payment': btoa(JSON.stringify(paymentRequest)),
'X-Payment-Network': config.network,
'X-Payment-Address': config.paymentAddress,
},
});
}Step 4: Create a Test Wallet
Create a dedicated wallet for testing. Never use your mainnet wallet.
// scripts/create-test-wallet.ts
import { Wallet } from 'ethers';
const wallet = Wallet.createRandom();
console.log('Address:', wallet.address);
console.log('Private Key:', wallet.privateKey);
console.log('Mnemonic:', wallet.mnemonic?.phrase);
// Save these somewhere safe but NOT in version controlFund this wallet with test ETH and test USDC.
Step 5: Write a Test Client
// scripts/test-x402-flow.ts
import { Wallet, JsonRpcProvider } from 'ethers';
const TESTNET_RPC = 'https://sepolia.base.org';
const API_URL = process.env.API_URL || 'http://localhost:3000';
const provider = new JsonRpcProvider(TESTNET_RPC);
const wallet = new Wallet(process.env.TEST_PRIVATE_KEY!, provider);
async function testPaymentFlow() {
console.log('🧪 Testing x402 payment flow on Base Sepolia');
console.log('Wallet:', wallet.address);
// Step 1: Make request without payment
console.log('\n1. Making unpaid request...');
const response = await fetch(`${API_URL}/api/tool`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: 'test' }),
});
if (response.status !== 402) {
throw new Error(`Expected 402, got ${response.status}`);
}
console.log('✓ Got 402 response');
// Step 2: Parse payment requirements
const paymentHeader = response.headers.get('X-Payment');
if (!paymentHeader) {
throw new Error('Missing X-Payment header');
}
const payment = JSON.parse(atob(paymentHeader));
console.log('\n2. Payment required:', {
amount: payment.amount,
network: payment.network,
recipient: payment.recipient,
});
// Verify it's testnet
if (payment.network !== 'base-sepolia') {
throw new Error(`Wrong network: ${payment.network}`);
}
console.log('✓ Correct testnet network');
// Step 3: Sign payment
console.log('\n3. Signing payment...');
const message = JSON.stringify({
amount: payment.amount,
currency: payment.currency,
recipient: payment.recipient,
nonce: payment.nonce,
expiry: payment.expiry,
});
const signature = await wallet.signMessage(message);
console.log('✓ Payment signed');
// Step 4: Retry with payment
console.log('\n4. Retrying with payment proof...');
const paidResponse = await fetch(`${API_URL}/api/tool`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Payment-Proof': btoa(JSON.stringify({
...payment,
signature,
signer: wallet.address,
})),
},
body: JSON.stringify({ input: 'test' }),
});
if (paidResponse.status !== 200) {
const error = await paidResponse.text();
throw new Error(`Payment failed: ${paidResponse.status} - ${error}`);
}
const result = await paidResponse.json();
console.log('✓ Payment accepted!');
console.log('\nResult:', JSON.stringify(result, null, 2));
console.log('\n🎉 Full payment flow working on testnet!');
}
testPaymentFlow().catch(console.error);Run it:
TEST_PRIVATE_KEY=0x... API_URL=http://localhost:3000 npx ts-node scripts/test-x402-flow.tsStep 6: Validate Edge Cases
Test: Expired Payment
async function testExpiredPayment() {
// Get payment request
const response = await fetch(`${API_URL}/api/tool`, { method: 'POST' });
const payment = JSON.parse(atob(response.headers.get('X-Payment')!));
// Modify expiry to the past
payment.expiry = Date.now() - 60000; // 1 minute ago
const signature = await wallet.signMessage(JSON.stringify(payment));
const paidResponse = await fetch(`${API_URL}/api/tool`, {
method: 'POST',
headers: {
'X-Payment-Proof': btoa(JSON.stringify({ ...payment, signature })),
},
});
// Should reject with 402
console.assert(paidResponse.status === 402, 'Should reject expired payment');
}Test: Wrong Amount
async function testWrongAmount() {
const response = await fetch(`${API_URL}/api/tool`, { method: 'POST' });
const payment = JSON.parse(atob(response.headers.get('X-Payment')!));
// Try to pay less
payment.amount = '0.001';
const signature = await wallet.signMessage(JSON.stringify(payment));
const paidResponse = await fetch(`${API_URL}/api/tool`, {
method: 'POST',
headers: {
'X-Payment-Proof': btoa(JSON.stringify({ ...payment, signature })),
},
});
console.assert(paidResponse.status === 402, 'Should reject underpayment');
}Test: Replay Attack
async function testReplayAttack() {
// Make a successful payment
const response = await fetch(`${API_URL}/api/tool`, { method: 'POST' });
const payment = JSON.parse(atob(response.headers.get('X-Payment')!));
const signature = await wallet.signMessage(JSON.stringify(payment));
const proof = btoa(JSON.stringify({ ...payment, signature }));
// First request should succeed
const r1 = await fetch(`${API_URL}/api/tool`, {
method: 'POST',
headers: { 'X-Payment-Proof': proof },
});
console.assert(r1.status === 200, 'First payment should succeed');
// Same proof should be rejected (nonce already used)
const r2 = await fetch(`${API_URL}/api/tool`, {
method: 'POST',
headers: { 'X-Payment-Proof': proof },
});
console.assert(r2.status === 402, 'Replay should be rejected');
}Step 7: CI/CD Integration
Add testnet tests to your pipeline:
# .github/workflows/test.yml
name: x402 Integration Tests
on: [push, pull_request]
jobs:
testnet:
runs-on: ubuntu-latest
environment: testnet
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Start server (testnet mode)
run: npm run dev &
env:
X402_NETWORK: base-sepolia
X402_FACILITATOR_URL: ${{ secrets.TESTNET_FACILITATOR_URL }}
PAYMENT_ADDRESS: ${{ secrets.TESTNET_PAYMENT_ADDRESS }}
- name: Wait for server
run: npx wait-on http://localhost:3000
- name: Run x402 testnet tests
run: npm run test:x402
env:
TEST_PRIVATE_KEY: ${{ secrets.TESTNET_WALLET_KEY }}
API_URL: http://localhost:3000Store your testnet wallet key as a GitHub secret. It's test money, but still keep it private.
Testnet vs Production Checklist
Before switching to mainnet:
| Check | Testnet | Mainnet |
|---|---|---|
| 402 response format | ✓ | Verify same format |
| Signature validation | ✓ | Same code path |
| Nonce tracking | ✓ | Same storage |
| Expiry handling | ✓ | Same logic |
| Facilitator URL | testnet.x402.org | x402.org |
| Payment address | Test wallet | Production wallet |
| Network in headers | base-sepolia | base |
| Chain ID | 84532 | 8453 |
Common Testnet Issues
"Invalid chain ID"
Your payment request says one chain, but the signature was made on another.
// Make sure chain ID matches
const config = getPaymentConfig();
console.log('Expected chain:', config.chainId);
console.log('Wallet chain:', await wallet.provider.getNetwork().chainId);"Insufficient funds"
Your test wallet doesn't have enough test USDC. Check:
cast call 0x036CbD53842c5426634e7929541eC2318f3dCF7e \
"balanceOf(address)" YOUR_WALLET_ADDRESS \
--rpc-url https://sepolia.base.org"Nonce already used"
You're replaying a payment. Each nonce should be unique. Make sure you're getting fresh 402 responses for each test.
"Facilitator unreachable"
The testnet facilitator might be down or rate-limited. Try again later, or run your own facilitator for testing.
Summary
- Get test tokens — ETH from faucet, USDC from Circle or self-deployed
- Configure testnet — different network, chain ID, facilitator
- Create test wallet — dedicated wallet for testing
- Test the full flow — 402 → sign → retry → success
- Test edge cases — expiry, wrong amount, replay
- Add to CI — automate testnet tests in your pipeline
Testnet testing catches bugs that mock mode misses. Always validate on testnet before handling real money.
Resources
- Base Sepolia Explorer
- Alchemy Base Faucet
- How to Test x402 APIs — the full testing guide
- How x402 Works — protocol deep dive
Questions? Email silas@agentutil.dev