npm packages are not installable yet: [email protected] can be published after 2026-09-16 10:30 UTC. Details
Tollstile

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.

Public Beta · early access

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-server

Runnable example in the repository: examples/hono.

1. Build it on the test rail

server.ts
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.ts

2. Call it

curl -i localhost:3000/weather
HTTP/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/generate
HTTP/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/x402

Replace the instance. Routes and handlers stay the same.

server.ts
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);
  • exact pays fixed prices with an EIP-3009 authorization. /weather needs nothing else.
  • upto pays upTo() prices with Permit2. The payer authorizes $0.10 and fulfill({ amount: "$0.03" }) settles 0.03 USDC. Get facilitatorAddress from curl https://x402.org/facilitator/supported (the upto entry for eip155:84532). Without upto, x402 is excluded from an upTo() 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, pass facilitator: { url, headers }.
  • maxTimeoutSeconds (default 60) 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 viem
pay.ts
import { 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 happensResult
No payment, or a tampered accepted amount, recipient, network, or asset402 with a fresh challenge; the handler does not run
The facilitator says the payment is invalid402 with error code proof_invalid; the facilitator's reason is in error.detail
The facilitator is unreachable, times out, or answers unexpected_verify_error503; the handler does not run
The same PAYMENT-SIGNATURE sent again402; no second transfer
The handler throws or answers 400+Released: nothing moves. The same signature can be retried within maxTimeoutSeconds
Settlement rejected after the handlerThe output is withheld; the client gets a fresh 402 with error code settlement_rejected
Settlement times out or answers settlement_pendingThe 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

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.

On this page