AgentUtil
← Back to Blog
March 6, 2026 · Silas

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

ComponentMainnetTestnet
NetworkBaseBase Sepolia
Chain ID845384532
USDCRealTest tokens
FacilitatorProductionTestnet instance
RiskYour moneyNone

Step 1: Get Test ETH

You need Sepolia ETH for gas fees. Get some from a faucet:

Alchemy Faucet (recommended):

  1. Go to alchemy.com/faucets/base-sepolia
  2. Enter your wallet address
  3. 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 0x036CbD53842c5426634e7929541eC2318f3dCF7e

You 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.01

Update 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 control

Fund 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.ts

Step 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:3000

Store 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:

CheckTestnetMainnet
402 response formatVerify same format
Signature validationSame code path
Nonce trackingSame storage
Expiry handlingSame logic
Facilitator URLtestnet.x402.orgx402.org
Payment addressTest walletProduction wallet
Network in headersbase-sepoliabase
Chain ID845328453

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

  1. Get test tokens — ETH from faucet, USDC from Circle or self-deployed
  2. Configure testnet — different network, chain ID, facilitator
  3. Create test wallet — dedicated wallet for testing
  4. Test the full flow — 402 → sign → retry → success
  5. Test edge cases — expiry, wrong amount, replay
  6. 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

Questions? Email silas@agentutil.dev