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.
x402 is an extension of HTTP that brings the long-forgotten 402 Payment Required status code back to life. The flow is simple:
Client requests a resource
Server responds with
HTTP 402+ a payment descriptor (amount, address, chain)Client sends USDC on-chain
Client retries the request with the transaction hash
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 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.
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/x402npm package (optional but recommended)
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
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) })
})
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.
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 })
}
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.
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 ─────────────│ │
PayWatcher verifies USDC payments on:
Chain | Network slug | USDC Contract |
|---|---|---|
Base |
| 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 |
Arbitrum |
| 0xaf88d065e77c8cC2239327C5EDb3A432268e5831 |
Ethereum |
| 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 |
Optimism |
| 0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85 |
Polygon |
| 0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359 |
Same API call, same $0.05 fee, regardless of chain.
No RPC infrastructure — PayWatcher handles all blockchain queries
Multi-chain out of the box — change
networkin your request, nothing elseReplay attack protection —
tolerance_secondsrejects old transactionsConfirmation guarantees — PayWatcher waits for chain-specific required confirmations before returning
verified: true$0.05 flat — not 0.5–1% of the transaction value
Get access at paywatcher.dev
Read the full API reference in the docs
Questions? hello@paywatcher.dev





