Monetize an API with x402
Charge per request in USDC with x402 on a Hono API. Build on the test rail, switch to x402 exact on Base Sepolia, and use upto for variable prices.
Goal: GET /weather costs $0.01 in USDC per call, POST /generate charges only what it used up to $0.10, and nothing is charged when a handler fails.
Prerequisites
- Node.js 22.18 or later, which runs TypeScript files directly. On older versions, run the files with
npx tsx. - For the x402 step: a receiving address (
payTo) and a payer wallet on Base Sepolia, funded with test USDC from https://faucet.circle.com, and a Base Sepolia JSON-RPC URL.
npm install tollstile @tollstile/hono hono @hono/node-serverRunnable example in the repository: examples/hono.
1. Build it on the test rail
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { tollstile } from "@tollstile/hono";
import { createTollstile, memoryLedger, testRail, upTo } from "tollstile";
const toll = createTollstile({
rails: [testRail()],
ledger: memoryLedger(),
onEvent: (event) => {
if (event.type === "charge.moved") console.log(`${event.charge.id} ${event.charge.payment}/${event.charge.fulfillment}`);
},
});
const app = new Hono();
app.get("/weather", tollstile(toll.price("$0.01")), (c) => c.json({ forecast: "clear" }));
app.post("/generate", tollstile(toll.price(upTo("$0.10"))), async (c) => {
const text = "…"; // do the work
await c.get("payment").fulfill({ amount: "$0.03" }); // settle what it cost
return c.json({ text });
});
serve({ fetch: app.fetch, port: 3000 });node server.ts2. Call it
curl -i localhost:3000/weatherHTTP/1.1 402 Payment Required
cache-control: no-store
content-type: application/json
{"error":{"code":"payment_required","retryable":true,"action":"pay","message":"Payment required: $0.01 for GET /weather.","detail":null},
"resource":"GET /weather","price":"$0.01","variable":false,
"quote":"eyJ2IjoxLCJpZCI6…","nonce":"VS_h…","expiresAt":"…",
"accepts":[{"rail":"test","asset":{"code":"USD","network":null,"scale":6},"amount":"10000","flow":"authorization",…}]}curl -i -H "Payment: test quote=eyJ2IjoxLCJpZCI6…" localhost:3000/weather
curl -i -X POST -H "Payment: test" localhost:3000/generateHTTP/1.1 200 OK
payment-receipt: test_settlement_chg_…
{"forecast":"clear"}The server log shows each charge ending settled/completed. For fixed prices, Payment: test without a quote also pays.
3. Switch to x402
npm install @tollstile/x402Replace the instance. Routes and handlers stay the same.
import { x402 } from "@tollstile/x402";
const toll = createTollstile({
rails: [
x402({
network: "eip155:84532", // Base Sepolia
payTo: process.env.PAY_TO!, // your receiving address
denomination: "USD", // 1 USDC = 1 USD, stated explicitly
rpcUrl: process.env.RPC_URL!, // e.g. https://sepolia.base.org, used by reconciliation
upto: { facilitatorAddress: process.env.UPTO_FACILITATOR! }, // enables upTo() prices
}),
],
ledger: memoryLedger(), // use a database ledger in production
secret: process.env.TOLLSTILE_SECRET!, // 32+ random characters; required with live rails
});
setInterval(() => void toll.reconcile(), 60_000);exactpays fixed prices with an EIP-3009 authorization./weatherneeds nothing else.uptopaysupTo()prices with Permit2. The payer authorizes $0.10 andfulfill({ amount: "$0.03" })settles 0.03 USDC. GetfacilitatorAddressfromcurl https://x402.org/facilitator/supported(theuptoentry foreip155:84532). Withoutupto, x402 is excluded from anupTo()route. The route remains valid when another configured rail supports variable authorization; it is refused only when no compatible rail remains.- On Base Sepolia the x402.org facilitator is the default. On mainnet (
eip155:8453) and other networks, passfacilitator: { url, headers }. maxTimeoutSeconds(default60) is how long the payer's signature is valid. The handler and settlement must both finish inside it. Raise it for slow handlers.
The test rail and live rails cannot run in one instance. Use one instance per environment.
Verify with a real client
curl -i localhost:3000/weather now returns 402 with a PAYMENT-REQUIRED header. echo <header> | base64 -d shows scheme: "exact", amount: "10000", and extra.tollstileQuote.
Pay with the reference x402 client. For upto, the payer must approve Permit2 (0x000000000022D473030F116dDEE9F6B43aC78BA3) for USDC once, which needs a little Base Sepolia ETH.
npm install @x402/fetch @x402/evm viemimport { x402Client, wrapFetchWithPayment, decodePaymentResponseHeader } from "@x402/fetch";
import { ExactEvmScheme } from "@x402/evm/exact/client";
import { UptoEvmScheme } from "@x402/evm/upto/client";
import { privateKeyToAccount } from "viem/accounts";
const signer = privateKeyToAccount(process.env.PAYER_KEY as `0x${string}`);
const client = new x402Client()
.register("eip155:84532", new ExactEvmScheme(signer))
.register("eip155:84532", new UptoEvmScheme(signer));
const pay = wrapFetchWithPayment(fetch, client);
const weather = await pay("http://localhost:3000/weather");
console.log(weather.status, decodePaymentResponseHeader(weather.headers.get("payment-response") ?? ""));
const generate = await pay("http://localhost:3000/generate", { method: "POST" });
console.log(generate.status, decodePaymentResponseHeader(generate.headers.get("payment-response") ?? ""));Expect 200 and a transaction hash for each. On https://sepolia.basescan.org, /weather shows a 0.01 USDC transfer to payTo; /generate shows 0.03 USDC through the upto proxy.
Verification status
The x402 rail is tested against a fake facilitator and a simulated chain, and its headers round-trip through the reference @x402/core. It has not been verified against a real facilitator or chain. Run the steps above on Base Sepolia before accepting real funds.
When things fail
| What happens | Result |
|---|---|
No payment, or a tampered accepted amount, recipient, network, or asset | 402 with a fresh challenge; the handler does not run |
| The facilitator says the payment is invalid | 402 with error code proof_invalid; the facilitator's reason is in error.detail |
The facilitator is unreachable, times out, or answers unexpected_verify_error | 503; the handler does not run |
The same PAYMENT-SIGNATURE sent again | 402; no second transfer |
The handler throws or answers 400+ | Released: nothing moves. The same signature can be retried within maxTimeoutSeconds |
| Settlement rejected after the handler | The output is withheld; the client gets a fresh 402 with error code settlement_rejected |
Settlement times out or answers settlement_pending | The output is served without a receipt; the charge is unknown until reconcile() finds the transfer on-chain. /settle is never called twice on a hunch |
x402 has no refunds. The rail only supports the authorization flow, so money moves only after your handler succeeded.
Next
x402 rail reference
Options, lookup, stored data.
Postgres ledger
Keep charges across restarts.
Run reconciliation
Accept MPP too
Retries
Send Idempotency-Key on the first paid request and keep it on retries of the same request (on MCP: _meta["tollstile/idempotency-key"]). A completed charge returns 409 already_paid; an in-flight charge returns 409 request_in_progress; an unknown outcome returns 503 payment_outcome_unknown. Do not start a new payment while the outcome is unknown. Released/refunded attempts may run again. See Idempotency for request matching and rail-specific key scopes.
Charge for MCP tool calls
Put a price on MCP tools with @tollstile/mcp. Build it on the test rail, call it from an MCP client, then accept x402 stablecoin payments.
Accept x402 payments in Express
Charge per request in USDC on an Express 5 API with @tollstile/express and the x402 rail, starting from the test rail.