edidiong umana · writing
home
Build it10 min read

Let agents pay for your API: a pay-per-call endpoint with x402

Build a small Express API where one route costs a tenth of a cent in test USDC, watch it answer 402 with its price, and wire up the client that pays. Then the questions production will ask: who pays the gas, what stops a replay, how refunds work, and how far to trust the facilitator.

An agent that wants your data can't do what a person does to get it. It can't fill in a sign-up form, wait for an API key to arrive by email, or type a card number into a checkout page. If your API only takes money that way, agents go somewhere else. x402 gives them another door: ask for the data, get told the price, pay, and get the data, all inside ordinary HTTP requests.

The idea in plain words

HTTP has long reserved a status code for this, 402 Payment Required, without ever saying how the payment should happen. x402 is an open protocol that fills that gap. Your server answers an unpaid request with 402 and a price list. The client signs a payment and asks again. Your server checks the payment, returns the data and settles the money.

Three words you'll meet below. A stablecoin is a token pegged to a currency; here it's USDC, a digital US dollar. A facilitator is a service that checks a signed payment and submits it to the blockchain for you, so your API never runs blockchain code. A testnet is a practice blockchain where the tokens are free and worth nothing, which is where everything in this post runs.

Why it matters: an agent that can act and pay needs to buy things one request at a time, with no account and no human at a checkout. A price of $0.001 per call is far below anything you'd put through a card checkout, but it's a normal x402 price.

How one paid request works

  1. Request. The client calls GET /proverb with no payment.
  2. 402 with requirements. The server answers 402 with a PAYMENT-REQUIRED header: base64-encoded JSON that lists what it accepts, meaning the scheme, network, token, amount, the address to pay, and a time limit.
  3. Signed payment header. The client picks an option, signs an authorisation for exactly that amount to exactly that address, and sends the request again with a PAYMENT-SIGNATURE header. Signing is not sending: no money has moved yet.
  4. Facilitator verify and settle. Your server passes the signed payload to the facilitator's /verify endpoint. If it's valid, your route handler runs. Then the server calls /settle, and the facilitator submits the transfer on-chain and pays the gas.
  5. 200 with the response header. The client gets your data plus a PAYMENT-RESPONSE header carrying the transaction hash, the network and the payer's address.
HeaderDirectionCarries
PAYMENT-REQUIREDserver to clientThe price list, on the 402
PAYMENT-SIGNATUREclient to serverThe signed authorisation, on the retry
PAYMENT-RESPONSEserver to clientThe settlement receipt, on the 200

Before you start

  • Node.js 20.6 or later, for the built-in --env-file flag. I ran everything below on Node 24.18.0 with npm 11.16.0.
  • A bash terminal (macOS, Linux, or Git Bash or WSL on Windows) and curl.
  • For the paying step only: test USDC on Base Sepolia from Circle's faucet, which gives 20 test USDC per address every two hours. The payer needs no test ETH, because the facilitator pays the gas.
  • No accounts or API keys. The public facilitator at https://x402.org/facilitator is free, needs no sign-up, and serves testnets only.

The packages, pinned: @x402/express@2.28.0, @x402/core@2.28.0, @x402/evm@2.28.0, @x402/fetch@2.28.0, express@5.2.1 and viem@2.57.3. These @x402/* packages implement version 2 of the protocol. The older x402-express and x402-fetch packages are still on npm at 1.2.0, but the current docs and examples use the scoped ones.

Build it, step by step

Step 1: create the project

mkdir proverb-api && cd proverb-api
npm init -y
npm pkg set type=module
npm i --save-exact express@5.2.1 @x402/express@2.28.0 @x402/core@2.28.0 @x402/evm@2.28.0 @x402/fetch@2.28.0 viem@2.57.3
printf ".env\nnode_modules\n" > .gitignore

The last line comes first on purpose: .env is ignored by git before any key exists.

Step 2: make two test wallets

You need an address to receive payments (the seller) and a wallet to pay from (the buyer). In real life these belong to different people; here both live on your machine. This script writes fresh testnet keys into .env and prints only the addresses, so no key ends up in your terminal history or in a chat with your coding agent.

// make-wallets.js
import { existsSync, writeFileSync } from "node:fs";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";

if (existsSync(".env")) {
  console.error(".env already exists, so nothing was changed.");
  process.exit(1);
}

const sellerKey = generatePrivateKey();
const buyerKey = generatePrivateKey();
const seller = privateKeyToAccount(sellerKey).address;
const buyer = privateKeyToAccount(buyerKey).address;

writeFileSync(
  ".env",
  [
    "# Testnet only. Never commit this file or reuse these keys on a mainnet.",
    `PAY_TO=${seller}`,
    `SELLER_PRIVATE_KEY=${sellerKey}`,
    `BUYER_PRIVATE_KEY=${buyerKey}`,
    "FACILITATOR_URL=https://x402.org/facilitator",
    "",
  ].join("\n"),
  { mode: 0o600 },
);

console.log(`Seller (PAY_TO): ${seller}`);
console.log(`Buyer:           ${buyer}`);
console.log("Keys saved to .env. Fund the buyer address with Base Sepolia USDC.");
node make-wallets.js
Seller (PAY_TO): 0x2e2c93603F916a70bE27740E47ABA750D45F7093
Buyer:           0x631F4a0B4d0781F1799B04829fc3409b939aC372
Keys saved to .env. Fund the buyer address with Base Sepolia USDC.

Your addresses will be different. The seller key is saved only so you can move any test USDC you earn; the server never reads it. If you already have a testnet wallet, put its address in PAY_TO instead.

Step 3: write the server

// server.js
import express from "express";
import { paymentMiddleware, x402ResourceServer } from "@x402/express";
import { ExactEvmScheme } from "@x402/evm/exact/server";
import { HTTPFacilitatorClient } from "@x402/core/server";

const PAY_TO = process.env.PAY_TO;
const FACILITATOR_URL = process.env.FACILITATOR_URL ?? "https://x402.org/facilitator";
const NETWORK = "eip155:84532"; // Base Sepolia testnet, in CAIP-2 form
const PORT = Number(process.env.PORT ?? 4021);

if (!PAY_TO) {
  console.error("Set PAY_TO in .env to the address that should receive payments");
  process.exit(1);
}

const proverbs = [
  "However long the night, the dawn will break.",
  "If you want to go fast, go alone. If you want to go far, go together.",
  "A tree is straightened while it is still young.",
  "Rain does not fall on one roof alone.",
];

// The facilitator checks signatures and settles payments on-chain for us.
const facilitator = new HTTPFacilitatorClient({ url: FACILITATOR_URL });
const resourceServer = new x402ResourceServer(facilitator)
  .register(NETWORK, new ExactEvmScheme());

const app = express();

// Only the routes listed here cost money. Everything else passes straight through.
app.use(
  paymentMiddleware(
    {
      "GET /proverb": {
        accepts: {
          scheme: "exact",
          price: "$0.001",
          network: NETWORK,
          payTo: PAY_TO,
        },
        description: "One proverb, chosen at random",
        mimeType: "application/json",
      },
    },
    resourceServer,
  ),
);

// Free route
app.get("/health", (req, res) => {
  res.json({ ok: true, paid_routes: ["GET /proverb"] });
});

// Paid route: this handler only runs after the payment has been verified
app.get("/proverb", (req, res) => {
  const proverb = proverbs[Math.floor(Math.random() * proverbs.length)];
  res.json({ proverb });
});

app.listen(PORT, () => {
  console.log(`Proverb API listening on http://localhost:${PORT}`);
});

Three details. The network is written in CAIP-2 form, namespace:reference, so eip155:84532 means the EVM chain with ID 84532, Base Sepolia. The price is in dollars, and the library turns it into USDC's smallest unit: USDC has six decimals, so $0.001 becomes 1000. And the middleware only guards the routes you list, so /health stays free.

Step 4: write the paying client

// client.js
import { wrapFetchWithPayment, x402HTTPClient } from "@x402/fetch";
import { x402Client } from "@x402/core/client";
import { ExactEvmScheme } from "@x402/evm/exact/client";
import { privateKeyToAccount } from "viem/accounts";

const PRIVATE_KEY = process.env.BUYER_PRIVATE_KEY;
const API_URL = process.env.API_URL ?? "http://localhost:4021/proverb";

if (!PRIVATE_KEY) {
  console.error("Set BUYER_PRIVATE_KEY in .env (a testnet-only key)");
  process.exit(1);
}

const signer = privateKeyToAccount(PRIVATE_KEY);
console.log(`Paying from ${signer.address}`);

// Teach the client to pay with the "exact" scheme on any EVM network.
// By default it only pays USDC-style assets and refuses anything over $1.
const client = new x402Client();
client.register("eip155:*", new ExactEvmScheme(signer));

// A drop-in replacement for fetch: on a 402 it signs, retries and returns the result.
const fetchWithPayment = wrapFetchWithPayment(fetch, client);
const httpClient = new x402HTTPClient(client);

const response = await fetchWithPayment(API_URL, { method: "GET" });
const result = await httpClient.processResponse(response);

console.log(`HTTP ${result.status} (${result.paymentStatus})`);
console.log("Body:", result.body);

if (result.paymentStatus === "settled") {
  console.log("Receipt:", JSON.stringify(result.header, null, 2));
} else if (result.header?.error) {
  console.log("Reason:", result.header.error);
}

This follows the buyer quickstart in the x402 docs. wrapFetchWithPayment does steps 2 to 5 of the flow for you, and processResponse decodes whichever payment header came back.

Run it and see it work

Start the server in one terminal:

node --env-file=.env server.js
Proverb API listening on http://localhost:4021

In a second terminal, the free route answers as usual:

curl -s http://localhost:4021/health
{"ok":true,"paid_routes":["GET /proverb"]}

The paid route answers 402. This is the real response from my run:

curl -si http://localhost:4021/proverb
HTTP/1.1 402 Payment Required
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
PAYMENT-REQUIRED: eyJ4NDAyVmVyc2lvbiI6MiwiZXJyb3IiOiJQYXltZW50IHJlcXVpcmVkIiwicmVzb3VyY2UiOnsidXJsIjoiaHR0cDovL2xvY2FsaG9zdDo0MDIxL3Byb3ZlcmIiLCJkZXNjcmlwdGlvbiI6Ik9uZSBwcm92ZXJiLCBjaG9zZW4gYXQgcmFuZG9tIiwibWltZVR5cGUiOiJhcHBsaWNhdGlvbi9qc29uIn0sImFjY2VwdHMiOlt7InNjaGVtZSI6ImV4YWN0IiwibmV0d29yayI6ImVpcDE1NTo4NDUzMiIsImFtb3VudCI6IjEwMDAiLCJhc3NldCI6IjB4MDM2Q2JENTM4NDJjNTQyNjYzNGU3OTI5NTQxZUMyMzE4ZjNkQ0Y3ZSIsInBheVRvIjoiMHgyZTJjOTM2MDNGOTE2YTcwYkUyNzc0MEU0N0FCQTc1MEQ0NUY3MDkzIiwibWF4VGltZW91dFNlY29uZHMiOjMwMCwiZXh0cmEiOnsibmFtZSI6IlVTREMiLCJ2ZXJzaW9uIjoiMiJ9fV19
Cache-Control: no-store
Content-Length: 2
ETag: W/"2-vyGp6PvFo4RvsFtPoIWeCReyIC8"
Date: Mon, 05 Oct 2026 02:51:46 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{}

The body is empty on purpose: in version 2, all the payment data travels in headers. Decode the header to read the price list:

node -e "fetch('http://localhost:4021/proverb').then(r => console.log(r.status, JSON.stringify(JSON.parse(atob(r.headers.get('payment-required'))), null, 2)))"
402 {
  "x402Version": 2,
  "error": "Payment required",
  "resource": {
    "url": "http://localhost:4021/proverb",
    "description": "One proverb, chosen at random",
    "mimeType": "application/json"
  },
  "accepts": [
    {
      "scheme": "exact",
      "network": "eip155:84532",
      "amount": "1000",
      "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
      "payTo": "0x2e2c93603F916a70bE27740E47ABA750D45F7093",
      "maxTimeoutSeconds": 300,
      "extra": {
        "name": "USDC",
        "version": "2"
      }
    }
  ]
}

That is the whole protocol from the seller's side: one route, one price, one address. asset is the USDC contract on Base Sepolia, and extra carries the token's name and version, which the client needs to build the exact message the token contract will check.

Do this todayRun the two curl commands above against your own server and decode the header. If you can read every field in that JSON, you understand what an agent sees when it meets your API.

Now run the client before funding the buyer. The facilitator checks the buyer's balance during verification and turns the payment down, so you get a second 402 with the reason. This is also real output:

node --env-file=.env client.js
Paying from 0x631F4a0B4d0781F1799B04829fc3409b939aC372
HTTP 402 (payment_required)
Body: {}
Reason: invalid_exact_evm_insufficient_balance

That one line proves a lot: the client parsed the 402, signed an authorisation, retried with it, and your server got a verdict back from the public facilitator.

Not run here: the funded payment. I couldn't fund a wallet from the machine where I tested this, so I can't paste a settled run. To finish it yourself, open faucet.circle.com, choose Base Sepolia, paste the buyer address and request USDC, then run node --env-file=.env client.js again. According to the x402 HTTP transport spec and the client's types, a successful run prints a 200 with a receipt of this shape (placeholders in angle brackets):

Paying from <buyer address>
HTTP 200 (settled)
Body: { proverb: '<one of the four proverbs>' }
Receipt: {
  "success": true,
  "transaction": "<transaction hash>",
  "network": "eip155:84532",
  "payer": "<buyer address>"
}

Paste the transaction hash into a Base Sepolia block explorer to see 0.001 USDC move from the buyer to PAY_TO, with the gas paid by someone else.

Going deeper

Why the payer needs no gas

The exact scheme on EVM chains uses EIP-3009, a token standard that USDC implements. Its transferWithAuthorization function moves tokens on the strength of a signed message from the holder: from, to, value, a validity window and a nonce. Anyone can submit that message, and whoever submits it pays the gas. In x402 the facilitator submits it. Because the amount and recipient are inside the signature, the spec notes that the facilitator cannot change either one; it only broadcasts. Tokens without EIP-3009 fall back to Permit2, which needs a one-time approval transaction first, so "gasless for the payer" holds cleanly only for EIP-3009 tokens.

Replay protection

Each authorisation carries a random 32-byte nonce, and the token contract records every nonce it has used, so the same signed payment can't settle twice. The window limits how long a stolen header stays useful: here maxTimeoutSeconds is 300, so the client signs an authorisation that expires five minutes later. The reference facilitator code in @x402/evm also reads the nonce's state on-chain during verification and rejects one that has already been used.

There is one gap to design for. In the middleware version I used, the order is verify, run your handler, then settle. As far as I can tell from the source, two copies of the same header sent at the same moment can both pass verification, because neither has settled yet. Only one settlement succeeds, and the other caller gets a 402 instead of your data, but your handler has run twice. Keep paid handlers free of side effects, or record nonces you have seen in a short-lived store and refuse duplicates before doing expensive work.

When the money actually moves

Reading the @x402/express 2.28.0 source, the middleware buffers your handler's response until settlement finishes. If your handler returns a status of 400 or above, it skips settlement and the caller isn't charged. If settlement fails, it throws the buffered body away and returns 402. Two consequences follow: your handler does its work before the money is final, and every paid call waits for a facilitator round trip and an on-chain transfer. If the connection drops after settlement, a retrying client signs a fresh nonce and pays again, since x402 has no idempotency key of its own. For anything above pocket change, accept an Idempotency-Key header and cache the result per payer.

Pricing per call versus per token

exact fits flat costs: a proverb, a lookup, a file. When cost depends on the work, as with tokens generated by a language model, the upto scheme lets the client authorise a maximum and the server settle the actual amount, which can be anything from zero to that cap. On EVM it uses Permit2. The public facilitator lists upto on Base Sepolia today. For prices so small that gas would exceed them, the spec also defines batch-settlement, where access is granted on a commitment and value moves later, which changes who carries the risk. Remember the client's default cap of $1 per payment when you set an upto maximum. I tested that cap by pricing a route at $2; the client refused before signing anything:

Error: Failed to create payment payload: All payment requirements were rejected by spendControls.maxAmountPerPayment ($1, including USDC). Raise maxAmountPerPayment, set it to false to disable, set allowedAssets[].maxAmountPerPayment for a per-asset atomic cap, or set spendControls: false to disable all spend controls.

Refunds, and what x402 doesn't do

x402 is a push payment that settles once. The spec defines no refund message, no chargeback and no dispute process. If you owe a caller money back, send an ordinary transfer to the payer address in the receipt and keep your own record. x402 also gives you no customer accounts, no invoices or tax records, and no fraud screening. The spec mentions combining it with Sign-In with Ethereum for discounted or identity-based pricing, but that is your code, not the protocol. Treat x402 as the till, not the shop.

How far to trust the facilitator

A facilitator can't redirect funds or change the amount. It can do other harm, though. It could report a bad payment as valid, and you would serve the data for nothing. It could report a settlement that never happened. It could go down and take your paid routes with it, and it sees the payer, amount and resource URL of every call. The defences are dull and effective: log every transaction hash from PAYMENT-RESPONSE, reconcile them against the chain and your PAY_TO balance, check high-value payments on-chain yourself, and choose a facilitator whose operator you have a relationship with. The protocol also lets you run your own: @x402/evm ships facilitator code, and the docs list what it needs, namely an RPC endpoint and a wallet holding gas.

Do the arithmetic for production, too. The public facilitator is testnet-only. Coinbase's hosted CDP facilitator needs an API key; its first 1,000 on-chain transactions each month are free, and each one after that costs $0.001. At a price of $0.001 per call, that fee would equal your whole price. Price per call well above your settlement cost, or batch.

Choosing a network

Ask four questions. Where do your payers already hold stablecoins? Does your facilitator support that network? Ask it with curl https://x402.org/facilitator/supported. Does the token support EIP-3009, so payers need no gas? And are the fees and confirmation time small next to your price and your latency budget? Start on a testnet and switch the CAIP-2 ID (Base mainnet is eip155:8453) only after you have reconciled a few hundred test payments. Base Sepolia is only one option: the public facilitator also lists Solana, Stellar, Aptos, Hedera, Algorand and XRPL test networks today, and you can point the client at any facilitator, including one you host. For example, I built Omni402, an open-source wrapper that turns an existing HTTP API into an x402 endpoint settling in stablecoins on Celo.

What production needs

  • A mainnet facilitator you trust, or your own, with its health in your monitoring.
  • PAY_TO owned by a wallet whose keys are backed up and kept off the API server.
  • Every receipt logged and reconciled daily against the chain.
  • Rate limits on unpaid requests, since a 402 still costs you a response.
  • Idempotency and side-effect-free paid handlers, as above.
  • Client-side caps on every agent you run, using the spend controls rather than a sentence in a prompt.

Your 30-minute build

Put your own tiny API behind a paywall. Pick one piece of data you could serve in a single JSON response, keep one route free and charge for one route on Base Sepolia.

Acceptance checks:

  • The free route returns 200 with no payment header.
  • The paid route returns 402, and the decoded PAYMENT-REQUIRED header shows your price, eip155:84532 and your PAY_TO.
  • The client run with an unfunded wallet prints invalid_exact_evm_insufficient_balance, and after funding it prints a 200 with a transaction hash you can find in a block explorer.
  • git status never shows .env, and no private key appears in your code or terminal output.

Three ideas to start from:

  • Market prices. Today's price per basket of tomatoes, pepper and onions at your local market, entered by you, sold per lookup to an agent planning a shopping trip.
  • Transport fares. The usual bus or keke fare between two stops in your city, so a trip-planning agent can quote a total before the rider leaves home.
  • Farming calendar. The planting window for a crop in a region, such as maize in the middle belt, for an agent that sends reminders to farmers by SMS.
Hand this to your coding agent
Build a tiny pay-per-call API with x402 in Node.js, on a testnet only.

Stack, pinned exactly:
- Node.js 20.6+ (use node --env-file=.env, no dotenv)
- express@5.2.1, @x402/express@2.28.0, @x402/core@2.28.0, @x402/evm@2.28.0, @x402/fetch@2.28.0, viem@2.57.3
- package.json must have "type": "module"

My data: [describe your data, e.g. "usual bus fares between 10 stops in my city", as a hard-coded array]

Files to create:
1. .gitignore containing .env and node_modules. Create it before any key exists.
2. make-wallets.js: generate a seller key and a buyer key with viem's generatePrivateKey, write PAY_TO, SELLER_PRIVATE_KEY, BUYER_PRIVATE_KEY and FACILITATOR_URL=https://x402.org/facilitator to .env (mode 0600), refuse to overwrite an existing .env, and print only the two addresses.
3. server.js: Express app on port 4021. One free route GET /health. One paid route GET /[my-route] guarded by paymentMiddleware from @x402/express with accepts { scheme: "exact", price: "$0.001", network: "eip155:84532", payTo: process.env.PAY_TO }, an x402ResourceServer registered with ExactEvmScheme from @x402/evm/exact/server, and HTTPFacilitatorClient from @x402/core/server pointing at FACILITATOR_URL.
4. client.js: x402Client from @x402/core/client registered for "eip155:*" with ExactEvmScheme from @x402/evm/exact/client and privateKeyToAccount(BUYER_PRIVATE_KEY); wrapFetchWithPayment from @x402/fetch; print status, paymentStatus, body, and either the settlement receipt or the 402 error reason using x402HTTPClient.processResponse.

Acceptance checks:
- curl -s http://localhost:4021/health returns 200 JSON.
- curl -si http://localhost:4021/[my-route] returns 402 with a PAYMENT-REQUIRED header; decoding it shows my price, eip155:84532 and my PAY_TO.
- node --env-file=.env client.js with an unfunded buyer prints invalid_exact_evm_insufficient_balance.
- .env is git-ignored and no private key is ever printed, logged or pasted into this chat.

Rules:
- Testnet only. Never use or ask me for a real key.
- Before you say you are finished, start the server, run every acceptance check above, and show me the real output. If a check fails, fix it and run all checks again.
- Tell me which step needs me: funding the buyer from https://faucet.circle.com (Base Sepolia) and rerunning client.js.

Learn it properly

This post is a slice of Track 1 of the AI Study Group, the Agentic AI engineer roadmap, which covers agents that pay, agent wallets and spending policies alongside the agent loop itself. It's free and self-paced.

Sources