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

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.

Public Beta · early access

Goal: GET /weather on an Express 5 app costs $0.01 in USDC, the receipt is on the response, and settlement finishes before the client sees a byte.

Prerequisites

  • Node.js 22.18 or later, which runs TypeScript files directly. On older versions, run the files with npx tsx.
  • Express 5.
  • For the x402 step: a receiving address and a payer wallet on Base Sepolia with test USDC from https://faucet.circle.com, and a Base Sepolia JSON-RPC URL.
npm install tollstile @tollstile/express express

Runnable example in the repository: examples/express.

1. Build it on the test rail

server.ts
import express from "express";
import { paid } from "@tollstile/express";
import { createTollstile, memoryLedger, testRail } from "tollstile";

const toll = createTollstile({ rails: [testRail()], ledger: memoryLedger() });
const app = express();

app.get(
  "/weather",
  paid(toll.price("$0.01"), (req, res, { payment }) => {
    res.json({ forecast: "clear", paidWith: payment.via });
  }),
);

app.get(
  "/users/:id",
  paid(toll.price("$0.01"), (req, res) => {
    res.json({ id: req.params.id }); // resource: "GET /users/:id"
  }),
);

app.listen(3000, () => console.log("listening on http://localhost:3000"));

paid(gate, handler, options?) wraps one route handler. The handler is called as handler(req, res, { payment, next }).

node server.ts
curl -i localhost:3000/weather                      # 402 Payment Required, signed quote in the body
curl -i -H "Payment: test" localhost:3000/weather   # 200 OK
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
payment-receipt: test_settlement_chg_…

{"forecast":"clear","paidWith":"rail"}

2. Switch to x402

npm install @tollstile/x402
server.ts
import { x402 } from "@tollstile/x402";

const toll = createTollstile({
  rails: [
    x402({
      network: "eip155:84532", // Base Sepolia; the x402.org facilitator is the default here only
      payTo: process.env.PAY_TO!,
      denomination: "USD",
      rpcUrl: process.env.RPC_URL!,
    }),
  ],
  ledger: memoryLedger(), // use a database ledger in production
  secret: process.env.TOLLSTILE_SECRET!, // 32+ random characters
});

setInterval(() => void toll.reconcile(), 60_000);

The routes do not change. For upTo() prices, add upto: { facilitatorAddress } as in Monetize an API with x402.

Behind a reverse proxy, set app.set("trust proxy", …) so the URL rails see matches the public one.

Verify

  1. curl -i localhost:3000/weather returns 402 with a PAYMENT-REQUIRED header.
  2. Pay with the reference client (@x402/fetch, @x402/evm, viem) using the pay.ts script against http://localhost:3000/weather.
  3. Expect 200, a payment-response header with a transaction hash, and one 0.01 USDC transfer to payTo on https://sepolia.basescan.org.

Verification status

The Express adapter is tested against a real Express 5.2 app on Node's HTTP server. The x402 rail is tested against a fake facilitator, a simulated chain, and the reference @x402/core, not against a real facilitator or chain.

When things fail

What happensPayment
The response sends its headers with status below 400Settled; receipt headers added
Status 400 or above, a thrown error, a rejected promise, or next(error)Released; nothing moves. The same signature can be retried
Settlement rejectedThe held response is replaced by a fresh 402 with error code settlement_rejected
Settlement outcome unknownThe response is sent without a receipt; reconcile() resolves the charge on-chain
Completing the payment fails (for example, the ledger is unreachable)The held response is discarded and the error goes to your Express error handlers

The outcome is decided the moment the response would send its headers, and the response is held until the payment completes. A streaming handler gets its first bytes out only after settlement; an error halfway through a stream does not undo the charge.

Dynamic prices need a body parser

Mount express.json() (or express.text() / express.raw()) before paid(). A dynamic price reads the parsed body from context.request, and the quote binds to it, so a quote cannot be replayed with a different body. Without a parser, a body-dependent price fails with CONFIG_INVALID instead of seeing an empty body.

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