Cover photo

How to Accept AI Agent Payments with x402 + PayWatcher

PayWatcher · April 2026

The x402 protocol is quietly becoming the standard for machine-to-machine payments. If you're building an API that AI agents consume — whether that's a data endpoint, a compute service, or a gated resource — x402 lets agents pay per request in USDC without human intervention.

This article walks through exactly how to implement x402 on your server using PayWatcher as the verification layer.


What is x402?

x402 is an extension of HTTP that brings the long-forgotten 402 Payment Required status code back to life. The flow is simple:

  1. Client requests a resource

  2. Server responds with HTTP 402 + a payment descriptor (amount, address, chain)

  3. Client sends USDC on-chain

  4. Client retries the request with the transaction hash

  5. Server verifies the payment and returns the resource

The protocol was pioneered by Coinbase and is gaining adoption across AI agent frameworks including MCP servers, Coinbase AgentKit, and custom agent runtimes.


The Verification Problem

The tricky part of x402 is step 5. Your server needs to answer one question:

"Did this transaction actually happen — with the right amount, to the right address, on the right chain?"

You have two options:

Option A: Roll your own — query an RPC node directly, parse ERC-20 Transfer logs, handle confirmations, manage multi-chain RPCs. This takes days, not hours.

Option B: Delegate to PayWatcher — one API call, $0.05 flat, works across Base, Arbitrum, Ethereum, Optimism, and Polygon.

This tutorial uses Option B.


Prerequisites

  • A PayWatcher account (request access at paywatcher.dev)

  • Your PayWatcher API key

  • Your deposit address configured in PayWatcher dashboard

  • Node.js + Express (or any HTTP framework)

  • The coinbase/x402 npm package (optional but recommended)


Step 1: Set Up Your Express Server

import express from "express"

const app = express()
app.use(express.json())

const PAYWATCHER_API_KEY = process.env.PAYWATCHER_API_KEY!
const DEPOSIT_ADDRESS = process.env.DEPOSIT_ADDRESS!
const RESOURCE_PRICE = "1.00" // $1.00 USDC

Step 2: Return HTTP 402 with Payment Descriptor

When a request comes in without payment, respond with a 402 and tell the client what to pay:

app.get("/api/report/:id", async (req, res) => {
  const paymentHeader = req.headers["x-payment"]

  if (!paymentHeader) {
    // No payment — return 402 with descriptor
    return res.status(402).json({
      scheme: "exact",
      network: "base",
      maxAmountRequired: "1000000", // $1.00 in USDC atomic units (6 decimals)
      resource: req.path,
      description: "Access to report",
      mimeType: "application/json",
      payTo: DEPOSIT_ADDRESS,
      maxTimeoutSeconds: 300,
      asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC on Base
      extra: { name: "USDC", version: "2" }
    })
  }

  // Payment header present — verify it
  const { txHash, network } = JSON.parse(paymentHeader as string)
  const verified = await verifyWithPayWatcher(txHash, network)

  if (!verified) {
    return res.status(402).json({ error: "Payment verification failed" })
  }

  // Payment verified — return the resource
  return res.json({ data: await getReport(req.params.id) })
})

Step 3: Verify with PayWatcher

async function verifyWithPayWatcher(
  txHash: string,
  network: string
): Promise<boolean> {
  const response = await fetch("https://api.paywatcher.dev/v1/payments/verify", {
    method: "POST",
    headers: {
      "x-api-key": PAYWATCHER_API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      tx_hash: txHash,
      network: network,
      amount: RESOURCE_PRICE,
      to: DEPOSIT_ADDRESS,
      tolerance_seconds: 300,
    }),
  })

  const { data } = await response.json()
  return data.verified === true
}

That's it. Three fields in, one boolean out.


Step 4: Handle retryafterseconds

PayWatcher returns a retry_after_seconds hint when a transaction is found but not yet confirmed:

async function verifyWithPayWatcher(txHash: string, network: string) {
  const response = await fetch("https://api.paywatcher.dev/v1/payments/verify", {
    method: "POST",
    headers: {
      "x-api-key": PAYWATCHER_API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      tx_hash: txHash,
      network: network,
      amount: RESOURCE_PRICE,
      to: DEPOSIT_ADDRESS,
    }),
  })

  const { data } = await response.json()

  if (!data.verified && data.retry_after_seconds) {
    // Transaction found but not confirmed yet — tell the client to retry
    return {
      verified: false,
      retryAfter: data.retry_after_seconds,
      reason: data.reason,
    }
  }

  return { verified: data.verified, retryAfter: null, reason: data.reason }
}

Return a Retry-After header so the agent knows when to try again:

if (!result.verified && result.retryAfter) {
  return res
    .status(402)
    .set("Retry-After", String(result.retryAfter))
    .json({ error: "Payment pending confirmation", reason: result.reason })
}

Step 5: Use PayWatcher's x402 Descriptor (Optional)

If you want PayWatcher to generate the exact x402 descriptor for you — including the correct USDC contract address per chain and amount in atomic units — add x402: true to your payment intent:

const intentResponse = await fetch("https://api.paywatcher.dev/v1/payments", {
  method: "POST",
  headers: {
    "x-api-key": PAYWATCHER_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    amount: RESOURCE_PRICE,
    network: "base",
    x402: true,
  }),
})

const { data } = await intentResponse.json()

// Return the pre-built x402 descriptor directly
return res.status(402).json(data.x402)

This is the easiest path if you want multi-chain support without hardcoding contract addresses per network.


Complete Flow

Client                    Your Server              PayWatcher
  │                           │                        │
  │── GET /api/report/42 ────>│                        │
  │<402 + descriptor ───────│                        │
  │                           │                        │
  │  [sends USDC on-chain]    │                        │
  │                           │                        │
  │── GET /api/report/42 ────>│                        │
  │   x-payment: {txHash}     │                        │
  │                           │── POST /verify ───────>│
  │                           │<─ { verified: true } ──│
  │<200 + data ─────────────│                        │

Supported Chains

PayWatcher verifies USDC payments on:

Chain

Network slug

USDC Contract

Base

base

0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913

Arbitrum

arbitrum

0xaf88d065e77c8cC2239327C5EDb3A432268e5831

Ethereum

ethereum

0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48

Optimism

optimism

0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85

Polygon

polygon

0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359

Same API call, same $0.05 fee, regardless of chain.


What You Get

  • No RPC infrastructure — PayWatcher handles all blockchain queries

  • Multi-chain out of the box — change network in your request, nothing else

  • Replay attack protectiontolerance_seconds rejects old transactions

  • Confirmation guarantees — PayWatcher waits for chain-specific required confirmations before returning verified: true

  • $0.05 flat — not 0.5–1% of the transaction value


Next Steps