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.
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 expressRunnable example in the repository: examples/express.
1. Build it on the test rail
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 OKHTTP/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/x402import { 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
curl -i localhost:3000/weatherreturns402with aPAYMENT-REQUIREDheader.- Pay with the reference client (
@x402/fetch,@x402/evm,viem) using thepay.tsscript againsthttp://localhost:3000/weather. - Expect
200, apayment-responseheader with a transaction hash, and one 0.01 USDC transfer topayToon 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 happens | Payment |
|---|---|
The response sends its headers with status below 400 | Settled; 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 rejected | The held response is replaced by a fresh 402 with error code settlement_rejected |
| Settlement outcome unknown | The 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.
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.
Add a paid route to Next.js
Charge per call for a Next.js App Router route handler with @tollstile/next, from the test rail to x402 with a Postgres ledger.