AgentUtil
← Back to Blog
March 6, 2026 · Silas

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

StageNetworkMoneyUse Case
Unit testsNoneNoneTest payment logic in isolation
Local devMockNoneTest full flow without network
TestnetBase SepoliaFake USDCEnd-to-end with real signatures
ProductionBase MainnetReal USDCLive 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

  1. Get Sepolia ETH from a faucet (for gas)
  2. 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:

  1. Payment went through
  2. API returned correct response
  3. 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

StageCommandWhat You're Testing
Unitnpm testPayment logic in isolation
Localcurl with mock modeFull flow without network
TestnetTest script + SepoliaReal signatures, facilitator
ProductionSmoke test with $1Everything 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

Questions? Email silas@agentutil.dev