# Using Tollstile with coding agents (/docs/coding-agents) Tollstile is designed to be picked up by coding agents. Give yours one of these, then describe the outcome you want. ## Machine-readable docs [#machine-readable-docs] | Resource | Use | | ---------------------------------- | ---------------------------------------------------------- | | [`/llms.txt`](/llms.txt) | Index of every page, grouped by task | | [`/llms-full.txt`](/llms-full.txt) | All docs as one Markdown file | | `/llms.mdx/docs//content.md` | Any single page as Markdown (the **Copy Markdown** button) | ## Agent skill [#agent-skill] The repository ships a skill at `skills/tollstile/SKILL.md` with the rules an agent needs: which package to install, how to price a route, how to choose a flow, and what never to do (floats for money, catching provider errors, skipping reconciliation). Add it to your agent's skills folder, or reference it from your project's `AGENTS.md`: ```md When adding payments, pay-per-call pricing, credits, or x402/MPP support, follow https://github.com/tollstile/tollstile/blob/main/skills/tollstile/SKILL.md ``` ## Prompts that work [#prompts-that-work] ```txt Add $0.05 pay-per-call pricing to GET /weather in this Hono app using Tollstile. Use the test rail for local development. ``` ```txt Let signed-in users with credits call POST /v1/generate for $0.02 per call using Tollstile credits, and require payment from everyone else. ``` ```txt Charge up to $0.50 per render with Tollstile and settle the actual cost after the render finishes. ``` ```txt Add a daily spend limit of $20 per payer to every paid route. ``` ## What a correct change looks like [#what-a-correct-change-looks-like] * `createTollstile` is created once and reused. * Prices are strings like `"$0.05"` or `upTo("$0.50")` — never numbers. * Routes are wrapped with the framework adapter, e.g. `tollstile(toll.price("$0.05"))` for Hono. * `toll.reconcile()` runs on a schedule in production. * Live rails come with a `secret` from the environment, and the test rail is never deployed next to them. # Core design (/docs/design) Per-call payment protocols are not "one request, one payment". x402 defines several flows, L402 credentials are reusable, KYAPay tokens are funded holds charged many times, MPP sessions are drawn down per call, and AP2 and Visa TAP bind evidence to verifier nonces. Tollstile's core separates: * **Quote** — what the server offered. Signed, immutable, never stored. See [Quotes](/docs/concepts/quotes). * **Authorization** — what the payer authorized: single-use or reusable up to a limit. * **Charge** — each economic effect against it, on a payment axis and a fulfillment axis. See [Authorizations and charges](/docs/concepts/authorizations-and-charges). * **Flow** — the order in which the two axes advance. See [Flows](/docs/concepts/flows). * **Reservation** — value held by policies such as credits follows reserve → commit / release. * **Reconciliation** — unknown outcomes are resolved by asking the provider. See [Run reconciliation](/docs/guides/reconciliation). The full design document is `DESIGN.md` in the repository; the TypeScript contracts in `packages/tollstile/src/core/types.ts` are authoritative. # Introduction (/docs) Tollstile adds paid access to any API route or MCP tool. Define **who pays**, **how much**, and **which payment protocols you accept** — Tollstile handles quotes, verification, replay protection, receipts, refunds, and reconciliation, without taking custody of your funds or your data. ```ts import { createTollstile, memoryLedger, testRail } from "tollstile"; import { tollstile } from "@tollstile/hono"; const toll = createTollstile({ rails: [testRail()], ledger: memoryLedger() }); app.get("/weather", tollstile(toll.price("$0.01")), (c) => c.json({ forecast: "clear" })); ``` An agent that calls `/weather` without paying gets `402 Payment Required` with a signed quote and an offer for every rail you accept. An agent that pays gets the response, and your ledger records the charge. Every package is implemented and tested against fakes, published test vectors, and reference libraries — the core, the rails (x402, MPP, L402, KYAPay), the SQL ledgers, and the MCP, Hono, Next.js, Express, and fetch adapters. None is verified against live providers yet, and nothing is on npm. Each rail's page states its verification status. ## What you get [#what-you-get] ## How it fits [#how-it-fits] ```txt request ─► adapter ─► access policies ─► rail verification ─► requirements ─► handler subscriber x402 · MPP · limit · credits L402 · KYAPay verifiedAgent · payPerCall userMandate │ ▼ ledger: authorizations · charges · claims ``` * **Rails** decide *how* an agent pays. * **Access policies** decide *whether* a caller has to pay. * **Requirements** add conditions every admitted request must meet. * **The ledger** is your operational record, reconciled with each provider. ## Tutorials [#tutorials] ## Using a coding agent? [#using-a-coding-agent] Point it at [`/llms.txt`](/llms.txt) and the [coding agents guide](/docs/coding-agents), or ask it directly: ```txt Add $0.05 pay-per-call pricing to this Hono endpoint using Tollstile. ``` # Installation (/docs/installation) ```bash npm install tollstile ``` | Package | Purpose | Status | | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | `tollstile` | Core: quotes, authorizations, charges, flows, reconciliation, access policies (`subscriber`, `credits`, `payPerCall`), requirements (`limit`, `payers`, `when`), test rail, memory ledger | Implemented | | `@tollstile/hono` | [Hono](/docs/adapters/hono) middleware (Node, Bun, Deno, Workers) | Implemented | | `@tollstile/mcp` | [MCP](/docs/adapters/mcp) tools on `@modelcontextprotocol/sdk` | Implemented; tested with the real SDK | | `@tollstile/express` | [Express](/docs/adapters/express) 5 routes | Implemented; tested with a real Express app | | `@tollstile/next` | [Next.js](/docs/adapters/nextjs) App Router route handlers | Implemented; not yet run inside a Next.js app | | `@tollstile/fetch` | [Web-standard handlers](/docs/adapters/fetch): Workers, Deno, Bun | Implemented; not yet run on Workers, Deno, or Bun | | `create-tollstile` | Project template with a paid route and a paying test agent | Implemented | | `@tollstile/x402` | [x402](/docs/rails/x402) rail (exact, upto) over HTTP and MCP | Implemented; not verified against a live facilitator or chain | | `@tollstile/mpp` | [MPP](/docs/rails/mpp) rails: Stripe charge, Tempo charge, Tempo session | Implemented; not verified against Stripe or Tempo. Tempo session is experimental | | `@tollstile/l402` | [L402](/docs/rails/l402) (Lightning) rail | Implemented; not verified against a Lightning node | | `@tollstile/kyapay` | [KYAPay](/docs/rails/kyapay) rail | Implemented; not verified against Skyfire | | `@tollstile/web-bot-auth` | [`verifiedAgent()`](/docs/guides/verified-agents-only) via HTTP message signatures | Implemented; not verified against a live agent | | `@tollstile/ap2` | `userMandate()` via AP2 mandates | Experimental | | `@tollstile/postgres` | [Postgres](/docs/ledgers/postgres) ledger: pg, postgres.js, Neon, PGlite | Implemented; tested on PGlite | | `@tollstile/sqlite` | [SQLite](/docs/ledgers/sqlite) ledger: node:sqlite, better-sqlite3, bun:sqlite, D1 | Implemented; tested on node:sqlite | Every package is tested against fakes, published test vectors, and reference libraries. None has been verified against live providers yet — real facilitators, chains, Stripe, Lightning nodes, or Skyfire. Each rail's page has a **Verification status** section with what was tested and how to check it live. Packages are not published yet. Until they are, build from the repository. Install commands in these docs show the published names. ## Create an instance [#create-an-instance] ```ts import { createTollstile, memoryLedger, testRail } from "tollstile"; export const toll = createTollstile({ rails: [testRail()], ledger: memoryLedger(), // Required with live rails: signs quotes. Use at least 32 random characters. // secret: process.env.TOLLSTILE_SECRET, }); ``` | Option | Default | Purpose | | ------------------- | ------------------------ | ------------------------------------------------------------- | | `rails` | — | Rails you accept. Test rails cannot be mixed with live rails. | | `ledger` | — | Where authorizations, charges, and claims are recorded. | | `secret` | random (test rails only) | Signs quotes. A list rotates: the first signs, all verify. | | `quoteTtlMs` | 5 minutes | How long a quote is honored. | | `providerTimeoutMs` | 10 seconds | Upper bound for any provider call. | | `clock` | system clock | Inject for tests. | | `onEvent` | — | Typed lifecycle events for logs and metrics. | # Philosophy (/docs/philosophy) ## The problem [#the-problem] Software is starting to buy software. Protocols such as x402 and MPP define how a payment is requested and proven. What nobody standardizes is everything a **merchant** builds around it: deciding who has to pay, verifying against your own price, settling at the right moment, refunding when the work didn't happen, surviving retries and crashes, and recording every outcome. Every team rebuilds this. Payment code rebuilt in a hurry is payment code that leaks money. ## Core beliefs [#core-beliefs] ### The lifecycle is the product, not the protocol [#the-lifecycle-is-the-product-not-the-protocol] Protocols will converge, fork, and absorb each other. What every merchant needs regardless is the same: price, grant access, verify, settle, fulfill, refund, record. ### Who pays is separate from how they pay [#who-pays-is-separate-from-how-they-pay] Rails answer *how* a payment is made. Access policies answer *whether* the caller must pay. They are configured separately and composed per route. ### Rails share a lifecycle, not a lowest common denominator [#rails-share-a-lifecycle-not-a-lowest-common-denominator] Every rail implements the same lifecycle contract and declares its capabilities: flows, single or reusable authorizations, variable amounts, quotes, refunds, and lookup. Mismatches fail at startup. ### You own the ledger [#you-own-the-ledger] Payment records belong in your database as your operational record; the provider stays the final authority on whether money moved, and reconciliation keeps the two in agreement. No Tollstile account, no required dashboard, no telemetry. ### Tollstile never takes custody of funds [#tollstile-never-takes-custody-of-funds] A library that holds money is a financial institution with a README. Tollstile verifies, asks the provider to settle or refund, and records. ### No duplicate economic effects [#no-duplicate-economic-effects] Exactly-once execution is not achievable across a network, a database, a provider, and your handler. Charges are state machines on a payment axis and a fulfillment axis; retries, replays, and recovery never settle or refund twice. Ambiguous outcomes are `unknown` until reconciled. ### Fail closed, and make trade-offs explicit [#fail-closed-and-make-trade-offs-explicit] A failure while verifying always denies. Flows and fulfillment are explicit choices, never hidden defaults. The price charged is the price quoted. ### The first paid request takes five minutes [#the-first-paid-request-takes-five-minutes] The test rail and memory ledger run the full lifecycle with no wallet, network, or account. ## How we decide [#how-we-decide] 1. **Money correctness** — no unpaid access, no duplicate economic effects, no hidden outcomes. 2. **Security** — verify everything, trust nothing from the wire. 3. **Neutrality** — no rail, provider, or platform gets special treatment. 4. **Developer experience** — small, typed, obvious. 5. **Simplicity** — less code, fewer concepts. 6. **Performance** — only with a benchmark. ## What we say no to [#what-we-say-no-to] * Claiming exactly-once execution. * Guessing the outcome of an ambiguous settlement. * Token swaps, currency conversion, or issuing a token. * A default or recommended rail. * Required cloud services, accounts, or telemetry. * Issuing identities. Tollstile verifies evidence; it does not issue it. * Deciding prices for you. ## Open source promise [#open-source-promise] Tollstile is MIT-licensed and complete. Everything needed to charge for APIs and tools in production is in the library, free. If a hosted product ever exists, it will offer things that genuinely require hosting — never features removed from the library. # Quickstart (/docs/quickstart) This guide prices a Hono route with the **test rail** and **memory ledger**, so there is nothing to sign up for. ## 1. Install [#1-install] ```bash npm install tollstile @tollstile/hono hono ``` Or start from a template: `npx create-tollstile my-paid-api`. ## 2. Price a route [#2-price-a-route] ```ts title="server.ts" import { Hono } from "hono"; import { createTollstile, memoryLedger, testRail } from "tollstile"; import { tollstile } from "@tollstile/hono"; const toll = createTollstile({ rails: [testRail()], ledger: memoryLedger(), }); const app = new Hono(); app.get("/weather", tollstile(toll.price("$0.01")), (c) => { return c.json({ forecast: "clear" }); }); export default app; ``` ## 3. Call it without paying [#3-call-it-without-paying] ```bash curl -i localhost:3000/weather ``` ```txt HTTP/1.1 402 Payment Required { "error": "payment_required", "price": "$0.01", "quote": "eyJ2IjoxLCJpZCI6…", "accepts": [{ "rail": "test", "amount": "10000", "flow": "authorization", … }] } ``` The `quote` is a signed record of what the server offered. It is never stored. ## 4. Pay with the quote [#4-pay-with-the-quote] ```bash curl -i -H "Payment: test quote=eyJ2IjoxLCJpZCI6…" localhost:3000/weather ``` ```txt HTTP/1.1 200 OK payment-receipt: test_settlement_chg_… { "forecast": "clear" } ``` The ledger now holds one authorization and one charge in `settled/completed`. For fixed prices, `Payment: test` without a quote also works. ## 5. Switch to a real rail [#5-switch-to-a-real-rail] Replace `testRail()` with live rails and `memoryLedger()` with a database ledger. The route does not change. The tutorials walk through it: * [Monetize an API with x402](/docs/guides/monetize-an-api-with-x402) * [Accept MPP payments](/docs/guides/accept-mpp-payments) * [Charge for MCP tool calls](/docs/guides/charge-for-mcp-tools) * [Charge per call on Cloudflare Workers](/docs/guides/cloudflare-workers) ## Next [#next] # Roadmap (/docs/roadmap) Tollstile is pre-release. v0.1 ships everything below together; nothing is on npm yet. ## v0.1 [#v01] | Area | Contents | Status | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | | Core | Quotes, authorizations, charges on two axes, `authorization` and `upfront` flows, variable prices, dynamic prices, request commitment, redaction, reconciliation, events | Implemented | | Policies and requirements | `subscriber`, `credits` with reservations, `payPerCall`, `limit`, `payers`, `when` | Implemented | | Test rail and memory ledger | Failure simulation, `tollstile/testing` helpers | Implemented | | Adapters | Hono, MCP, Express, `create-tollstile` | Implemented; tested with the real frameworks | | Adapters | Next.js, fetch (Workers, Deno, Bun) | Implemented; tested with Web-standard requests, not yet run inside Next.js or on Workers, Deno, or Bun | | Rails | x402 (exact, upto), MPP (Stripe charge, Tempo charge), L402, KYAPay | Implemented; tested against fakes, published vectors, and reference libraries; not verified against live providers | | Rails | MPP Tempo session | Experimental | | Requirements | `verifiedAgent()` (Web Bot Auth) | Implemented; tested against RFC vectors, not verified against a live agent | | Requirements | `userMandate()` (AP2) | Experimental | | Ledgers | Postgres, SQLite (including D1) | Implemented; tested on PGlite and node:sqlite | ## Before v0.1 is published [#before-v01-is-published] * Verify each rail against its live provider: x402 on Base Sepolia with a real facilitator, Stripe test mode and Tempo Moderato for MPP, LND on regtest for L402, the Skyfire sandbox for KYAPay. * Run the Next.js and fetch adapters inside Next.js and on Workers, Deno, and Bun, and the D1 and Neon ledger adapters against those services. ## After v0.1 [#after-v01] * `escrow` flow (settle a deposit, then the final amount) * OpenTelemetry package built on `onEvent` * `npx tollstile reconcile` CLI ## Out of scope [#out-of-scope] | Not planned | Why | | -------------------------------------- | ------------------------------------------------------------------------- | | Commerce checkout protocols (ACP, UCP) | Catalogs, carts, and orders are a different problem from per-call payment | | Handling raw card data | Cards go through a provider | | Custody, swaps, or a token | Tollstile never takes custody of funds | | Issuing agent identities | Tollstile verifies evidence; it does not issue it | # Express (/docs/adapters/express) ```bash npm install tollstile @tollstile/express express ``` ```ts title="server.ts" import express from "express"; import { createTollstile, memoryLedger, testRail } from "tollstile"; import { paid } from "@tollstile/express"; 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.listen(3000); ``` ```bash curl -i localhost:3000/weather # 402 Payment Required curl -i -H "Payment: test" localhost:3000/weather # 200 OK, payment-receipt: test_settlement_… ``` `paid(gate, handler, options?)` wraps one route handler. Unpaid requests get a `402` with every rail's challenge. Paid requests run your handler, and the response is held at the moment it would send its headers until the payment is completed, so the receipt is on the response and settlement has finished before anything reaches the client. Tutorial: [Accept x402 payments in Express](/docs/guides/x402-with-express). ## Behavior [#behavior] The handler is called as `handler(req, res, { payment, next })`. | What happens | Payment | | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | The response sends its headers with status below `400` | Completed as `succeeded`: settled on the `authorization` flow, receipt headers added | | The response sends its headers with status `400` or above | Completed as `failed`: released, or refunded on the `upfront` flow | | The handler throws or rejects | Completed as `failed`, then Express receives the error | | The handler calls `next(error)` | Completed as `failed` when the error response is sent | | The handler calls `next()` | Decided by whichever handler sends the response | | Settlement is rejected | The held response is discarded and a fresh `402` with `reason: "settlement_rejected"` is sent instead | | Settlement is unknown | The response is sent without a receipt; reconciliation resolves the charge | * **When the outcome is decided.** The first call that would send headers — `res.send`, `res.json`, `res.end`, `res.writeHead`, `res.write`, `res.flushHeaders`, or a piped stream — decides the outcome from the status code. That call and everything after it are held until the payment completes. A streaming handler gets its first bytes out only after settlement, and an error halfway through a stream does not undo the charge. * **When completion fails** (for example, the ledger is unreachable), the held response is discarded and the error goes to your Express error handlers. If control had already left the handler through `next()` or a thrown error, the connection is closed instead. * Writes made while the response is held return `false`; `'drain'` is emitted once they are let through, so piped streams resume. * Call `payment.fulfill()` inside the handler to mark the service as delivered earlier; a later failure then does not undo the charge. * **Resource.** `" "`, e.g. `GET /api/users/:id`, when the handler is on a string route path, and `" "` otherwise (for example under `app.use`). Mount paths come from `req.baseUrl`, so a mount path with parameters is recorded with its values; set `toll.price(amount, { resource })` there. * **Request.** Rails read proofs from a Web `Request` built from `req`: the method, the absolute URL from `req.protocol`, `req.host`, and `req.originalUrl` (both honor Express's `trust proxy` setting), and every header. The body is rebuilt from what Express parsed. Run `express.json()`, `express.text()`, or `express.raw()` before `paid()`. A dynamic price then reads the body from `context.request`, and the quote binds to it: parsed objects are serialized with sorted keys, so a retry with the same JSON matches. If a request has a body no parser read, pricing it fails with `CONFIG_INVALID` rather than pricing an empty body. Fixed-price routes never read the body. ## Options [#options] | Option | Type | Description | | ----------- | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | `principal` | `(req: express.Request) => Principal \| null \| Promise` | Resolves the authenticated caller for `subscriber()` and `credits()`. Defaults to no principal. | ## Verification status [#verification-status] Tested against a real Express 5.2 app on Node's HTTP server with `fetch`: the `402` → pay with the quote → `200` round trip; `res.json`, `res.send`, `res.writeHead`, a piped stream larger than the socket buffer, and writers waiting for `'drain'`; completion finishing before the client sees the response; releases on thrown errors, rejected promises, `next(error)`, and `4xx`; `next()` to a later handler; a failing completion replacing the response; route-path resource names; and principals reaching `credits()`. Not tested with middleware that patches the response, such as `compression`, or behind a reverse proxy. To verify such a setup, run the two `curl` commands: the second must return `200` with a `payment-receipt` header and the full body, and your ledger must show the charge `settled` before the response was received. # Fetch (Workers, Deno, Bun) (/docs/adapters/fetch) ```bash npm install tollstile @tollstile/fetch ``` ```ts title="src/index.ts" import { createTollstile, memoryLedger, testRail } from "tollstile"; import { paid } from "@tollstile/fetch"; const toll = createTollstile({ rails: [testRail()], ledger: memoryLedger() }); const weather = paid(toll.price("$0.01"), (request, { payment }) => Response.json({ forecast: "clear", paidWith: payment.via }), ); export default { fetch: weather }; // Workers // Bun: Bun.serve({ fetch: weather }) · Deno: Deno.serve(weather) ``` ```bash curl -i localhost:8787/weather # 402 Payment Required curl -i -H "Payment: test" localhost:8787/weather # 200 OK, payment-receipt: test_settlement_… ``` `paid(gate, handler)` turns a priced route into a `(request) => Promise` handler. Unpaid requests get a `402` with every rail's challenge; paid requests run your handler, and the payment is completed — settled, or released — before the response is returned, with the rail's receipt headers on it. It guards one handler and does not route. Match paths yourself, or use [Hono](/docs/adapters/hono) on the same runtimes. Tutorial: [Charge per call on Cloudflare Workers](/docs/guides/cloudflare-workers). ## Behavior [#behavior] | Handler result | Payment | | ------------------------------------- | --------------------------------------------------------------------------------------------------------- | | A response with status below `400` | Completed as `succeeded`: settled on the `authorization` flow, receipt headers added | | A response with status `400` or above | Completed as `failed`: released, or refunded on the `upfront` flow | | Throws or rejects | Completed as `failed`, then the error is rethrown | | Settlement rejected | The response body is cancelled and a fresh `402` with `reason: "settlement_rejected"` is returned instead | | Settlement unknown | The response is returned without a receipt; reconciliation resolves the charge | * Call `payment.fulfill()` inside the handler to mark the service as delivered earlier; a later failure then does not undo the charge. * Responses with immutable headers (from `fetch()` or `Response.redirect()`) are copied so the receipt can be added. The copy keeps the status, status text, headers, and the unread body stream. * The resource is `" "`, without the query string. Name routes with parameters: `toll.price("$0.01", { resource: "GET /users/:id" })`. * On Workers, create the instance once per isolate and pass a shared `secret`, so quotes issued by one isolate verify in another. Use a database ledger such as [SQLite on D1](/docs/ledgers/sqlite). ## Options [#options] `paid(gate, handler, options?)` | Option | Type | Description | | ----------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | `principal` | `(request: Request) => Principal \| null \| Promise` | Resolves the authenticated caller for `subscriber()` and `credits()`. Defaults to no principal. | ## Verification status [#verification-status] Tested with Node 22's `Request` and `Response` against the test rail, memory ledger, and memory balance: the `402` → pay with the quote → `200` round trip, receipts on mutable and immutable responses, streamed bodies, releases on thrown errors and `4xx`, a single completion per request, and principals reaching `credits()`. Not run on Cloudflare Workers, Deno, or Bun. To verify on a runtime, run the example (`wrangler dev`, `deno run --allow-net`, or `bun run`) and the two `curl` commands: the first must return `402` with a `quote` in the body, the second `200` with a `payment-receipt` header. # Hono (/docs/adapters/hono) ```ts title="server.ts" import { Hono } from "hono"; import { tollstile } from "@tollstile/hono"; import { toll } from "./toll"; const app = new Hono(); app.post( "/v1/generate", tollstile(toll.price("$0.04"), { principal: (c) => (c.get("user") ? { id: c.get("user").id } : null), }), async (c) => { const payment = c.get("payment"); // typed from your rails return c.json(await generate(await c.req.json())); }, ); export default app; ``` | Option | Purpose | | ----------- | -------------------------------------------------------------------- | | `principal` | Resolves the authenticated caller for `subscriber()` and `credits()` | * The resource name is `METHOD /route/:pattern` from Hono's matched route. * The handler succeeds when it returns a response below `400` without throwing; otherwise the charge is released or refunded. * Receipt headers are appended to your response. * `c.get("payment")` exposes the payment, including `fulfill({ amount })` for `upTo()` prices. * If settlement is rejected after the handler, the response is replaced by a fresh `402` with `reason: "settlement_rejected"`; the payer does not get the output. If the outcome is unknown, the response is sent without a receipt and reconciliation resolves the charge. Tutorial: [Monetize an API with x402](/docs/guides/monetize-an-api-with-x402). # MCP (/docs/adapters/mcp) ```bash npm install tollstile @tollstile/mcp @modelcontextprotocol/sdk ``` `@modelcontextprotocol/sdk` 1.23 or later is required: earlier versions turn every error thrown from a tool callback into a tool result, so MPP's JSON-RPC error could not reach the client. ```ts title="server.ts" import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { paidTool } from "@tollstile/mcp"; import { createTollstile, memoryLedger, testRail } from "tollstile"; const toll = createTollstile({ rails: [testRail()], ledger: memoryLedger() }); const server = new McpServer({ name: "weather", version: "1.0.0" }); paidTool(server, "forecast", { description: "Tomorrow in one word" }, toll.price("$0.01"), (_args, { payment }) => ({ content: [{ type: "text", text: `clear (paid via ${payment.via})` }], })); await server.connect(new StdioServerTransport()); ``` Tools registered with `server.registerTool` stay free. Tutorial: [Charge for MCP tool calls](/docs/guides/charge-for-mcp-tools). ## API [#api] ```ts paidTool(server, name, config, gate, handler, options?): RegisteredTool ``` | Parameter | | | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `server` | An `McpServer`. | | `name`, `config` | As for `server.registerTool`: `title`, `description`, `inputSchema`, `outputSchema`, `annotations`, `_meta`. | | `gate` | `toll.price(...)`. | | `handler` | `(args, extra) => CallToolResult`. `args` is validated against `inputSchema` (`undefined` without one). `extra` is the SDK's request context plus `payment`. | | `options.principal` | `(extra) => Principal \| null \| Promise<…>`. Resolves the caller for `subscriber()` and `credits()`, e.g. from `extra.authInfo` or `extra.requestInfo.headers`. | ## Context passed to the gate [#context-passed-to-the-gate] | Field | Value | | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `transport` | `"mcp"` | | `request` | Under Streamable HTTP and SSE, a `Request` rebuilt from the URL and headers of the HTTP POST that carried the call, without a body. `null` for stdio and in-memory transports. | | `mcp` | `{ tool, arguments, meta, clientCapabilities }`: the tool name, its arguments, `params._meta`, and the client's declared capabilities, each checked to be plain JSON. A call whose `_meta` or arguments are not JSON is refused with `invalid_request` before the gate runs. | | `principal` | From `options.principal`, or `null`. | | `resource` | The gate's `resource` option, or `tool:`. | | `requestId` | `crypto.randomUUID()` per call. | | `extras` | The SDK's `extra`. | Dynamic prices commit to the tool name and its canonical arguments, so a quote for one set of arguments cannot pay for another. ## Denials [#denials] | Denial | Rendered as | | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `402`, a rail offers MPP, and the client declared `capabilities.experimental.payment` | JSON-RPC error `-32042`, `data: { httpStatus: 402, challenges: [...], failure?: { reason } }` | | `402`, a rail offers x402 | Tool result `isError: true`, `structuredContent` = the x402 `PaymentRequired` (with `error` set to the failure reason, if any), `content[0].text` = its JSON | | Any other `402`, and `400` / `403` / `429` / `503` | Tool result `isError: true`, `content[0].text` = Tollstile's denial body | Every denial rendered as a tool result also carries Tollstile's full denial body — every rail's offer and the signed quote — in `_meta["tollstile/payment-required"]`. | Rail | Proof in `_meta` | Receipt in the result's `_meta` | | --------- | ---------------------------- | ------------------------------- | | Test rail | `tollstile/test-payment` | `tollstile/test-receipt` | | x402 | `x402/payment` | `x402/payment-response` | | MPP | `org.paymentauth/credential` | `org.paymentauth/receipt` | | L402 | `l402/credential` | `l402/receipt` | | KYAPay | `kyapay/token` | `kyapay/receipt` | ## Outcome [#outcome] The call **succeeded** — and settles, on the `authorization` flow — when the handler returns a result without `isError: true` whose `structuredContent` matches `outputSchema`, if the tool has one. `paidTool` checks the schema before the SDK does, so output the SDK would reject is not charged. Otherwise the call **failed**: a handler that throws, returns `isError: true`, or returns output that does not match `outputSchema` releases the reservation (or refunds, on `upfront`). A thrown error is rethrown for the SDK to render. | After the handler | Client gets | | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Settled | The result, with the receipt merged into `_meta` | | Settlement rejected | **Not the output.** A fresh payment requirement, rendered as above, with reason `settlement_rejected` | | Settlement unknown | The result without a receipt; [reconciliation](/docs/guides/reconciliation) resolves the charge. Withholding it would charge for a service never delivered if the charge later reconciles as settled | | Nothing charged (failed, subscriber, credits) | The handler's own result | For work that exists before the handler returns, call `extra.payment.fulfill()`; a later failure then does not undo the charge. ## Known limitations [#known-limitations] * **MPP verification failures use `-32042`, not `-32043`.** McpServer passes only `-32042` through from a tool callback. The reason is in `data.failure.reason`, next to a fresh challenge. * **x402 denials on tools with an `outputSchema`.** The x402 transport requires `structuredContent` on the payment-required result; a client that validates it against the tool's `outputSchema` rejects it. * Do not call `RegisteredTool.update()` with a new `callback` (it bypasses the gate), or to add or remove `inputSchema`. ## Verification status [#verification-status] * Tested with the real SDK `McpServer` and `Client` over `InMemoryTransport`, and `WebStandardStreamableHTTPServerTransport` for the HTTP request and principal: quote round-trip, tampered quote, replay, retry after a released charge, handler throw, `isError`, `outputSchema` mismatch, provider outage (`503`), `403`, non-JSON `_meta`, `credits()` with a principal, and rejected settlement. * The x402 and MPP renderings are tested against fake rails shaped like the x402 MCP transport and the MPP MCP transport draft. They are **not** tested against `@x402/mcp`, `mppx`, or a real paying client. # Next.js (/docs/adapters/nextjs) ```bash npm install tollstile @tollstile/next ``` ```ts title="lib/toll.ts" import { createTollstile, memoryLedger, testRail } from "tollstile"; export const toll = createTollstile({ rails: [testRail()], ledger: memoryLedger() }); ``` ```ts title="app/reports/[id]/route.ts" import { paid } from "@tollstile/next"; import { toll } from "@/lib/toll"; export const GET = paid( toll.price("$0.01", { resource: "GET /reports/[id]" }), async (request, { params, payment }) => { const { id } = await params; return Response.json({ id, paidWith: payment.via }); }, ); ``` ```bash curl -i localhost:3000/reports/42 # 402 Payment Required curl -i -H "Payment: test" localhost:3000/reports/42 # 200 OK, payment-receipt: test_settlement_… ``` `paid(gate, handler)` returns a route handler. Unpaid requests get a `402` with every rail's challenge; paid requests run your handler with `{ params, payment }`, and the payment is completed — settled, or released — before the response is returned. The package has no dependency on `next`. Route handlers only: pages, server components, and server actions are not guarded. Tutorial: [Add a paid route to Next.js](/docs/guides/nextjs-paid-route). ## Behavior [#behavior] | Handler result | Payment | | ------------------------------------- | --------------------------------------------------------------------------------------------------------- | | A response with status below `400` | Completed as `succeeded`: settled on the `authorization` flow, receipt headers added | | A response with status `400` or above | Completed as `failed`: released, or refunded on the `upfront` flow | | Throws or rejects | Completed as `failed`, then the error is rethrown | | Settlement rejected | The response body is cancelled and a fresh `402` with `reason: "settlement_rejected"` is returned instead | | Settlement unknown | The response is returned without a receipt; reconciliation resolves the charge | * `redirect()` and `notFound()` from `next/navigation` work by throwing, so they count as failures. Return `NextResponse.redirect()` when a redirect is the paid result. * Call `payment.fulfill()` inside the handler to mark the service as delivered earlier; a later failure then does not undo the charge. * The resource is `" "`, without the query string. Dynamic segments make that set unbounded, so name the route with `toll.price(amount, { resource })`. * Responses with immutable headers (from `fetch()` or `Response.redirect()`) are copied so the receipt can be added; the copy keeps the status, headers, and unread body stream. * The handler receives the request typed as `Request`. Next.js passes a `NextRequest`; use `new URL(request.url)` for the URL. * `memoryLedger()` lives in one process. On serverless deployments use a [database ledger](/docs/ledgers/postgres), and give every instance the same `secret`. ## Options [#options] `paid(gate, handler, options?)` | Option | Type | Description | | ----------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | `principal` | `(request: Request) => Principal \| null \| Promise` | Resolves the authenticated caller for `subscriber()` and `credits()`. Defaults to no principal. | ## Verification status [#verification-status] Tested by calling the exported handler the way Next.js does — `(request, { params: Promise })` — against the test rail, memory ledger, and memory balance: the `402` → pay with the quote → `200` round trip, params passthrough, receipts on immutable responses, releases on thrown errors and `4xx`, a single completion per request, principals reaching `credits()`, and type assignability for static, dynamic, and catch-all routes. Not run inside a Next.js application. To verify, add the example to an app, run `next build` (which type-checks route exports) and `next dev`, then the two `curl` commands. # How Tollstile compares (/docs/compare/overview) Tollstile does not compete with payment protocols or providers — it sits on top of them. The question it answers: **once an agent can pay, what does the merchant still have to build?** | | Raw x402 middleware | MPP SDK | Edge gateway | Stripe alone | Tollstile | | --------------------------------------------- | ------------------- | --------------- | ------------------ | ---------------- | --------------------- | | Several 402 protocols on one route | One | One | The platform's | Stripe's | Any configured rail | | Runs inside your app | Yes | Yes | At the edge | Via API | Yes | | Price locked by a signed quote | — | Challenge-bound | Varies | — | Yes, across all rails | | Subscribers, credits, pay-per-call | Build it | Build it | Varies | Build it | Built in | | Release or refund when the handler fails | Build it | Build it | Varies | Build it | Built in | | Unknown outcomes reconciled with the provider | Build it | Build it | Varies | Per API call | Built in | | Payment records live in | Your code | Your code | Platform dashboard | Stripe dashboard | Your database | | Local development without a wallet | Testnet | Test mode | Varies | Test mode | Test rail | | Moves funds | Facilitator | Provider | Provider | Stripe | Never | # Tollstile vs MPP SDKs (/docs/compare/tollstile-vs-mpp-sdk) MPP is payment-method agnostic: one protocol for stablecoins, cards through Stripe, and sessions. If MPP is the only protocol you will accept, its SDK covers the wire format — challenges, credentials, and receipts. ## What Tollstile adds [#what-tollstile-adds] * **Merchant lifecycle across protocols:** access policies, requirements, a ledger, and reconciliation that work the same for MPP, x402, and others. * **Correct timing per method:** a Stripe charge captures when the PaymentIntent is confirmed, so Tollstile settles it before your handler and refunds when the handler fails; a Tempo charge is broadcast only after the handler succeeded; Tempo sessions (experimental) are reusable authorizations drawn down per call. * **Replay protection beyond the challenge HMAC**, which does not prevent reuse by itself. * **Recovering timed-out charges** by looking them up instead of failing the request or charging again. The MPP rails are implemented and tested against in-process fakes and mppx's published challenge vectors, not yet against Stripe or a Tempo node. The Tempo session rail is experimental. See [MPP](/docs/rails/mpp). # Tollstile vs Stripe (/docs/compare/tollstile-vs-stripe) **Stripe moves money. Tollstile works the gate.** Stripe can accept agent payments through its machine payments products. If Stripe is your only provider and you are happy to build access rules, refunds on handler failure, and records yourself, Stripe alone is enough. Tollstile is for when you want: * **Any 402 rail — Stripe's included** — on the same route. * **Subscribers, credits, and spend limits** evaluated before anyone pays. * **A ledger in your own database** that survives changing providers. * **Reconciliation** that resolves timeouts by asking the provider. Tollstile charges nothing and never holds funds. Your provider still charges its own fees. # Tollstile vs raw x402 middleware (/docs/compare/tollstile-vs-x402) The x402 SDKs give you middleware that answers `402` with `PAYMENT-REQUIRED`, verifies a `PAYMENT-SIGNATURE` through a facilitator, and settles. That is the protocol layer, and Tollstile's x402 rail speaks it. ## Use raw x402 middleware when [#use-raw-x402-middleware-when] * x402 is the only way you will ever get paid, * every caller pays per call, and * you are comfortable handling settlement failures and records yourself. ## Use Tollstile when you also need [#use-tollstile-when-you-also-need] * **The same route to accept MPP, L402, or KYAPay** alongside x402. * **Subscribers and credits** that skip or replace the payment. * **Nothing charged when the handler fails** — the authorization is released instead of settled. * **Settlement you can trust after a timeout.** x402's `/settle` is not idempotent and has no status endpoint; Tollstile records `unknown` and reconciles on-chain instead of guessing. * **Pay-for-what-ran** with `upTo()` and `payment.fulfill({ amount })` on the `upto` scheme. * **A ledger in your database**, spend limits per payer, and a test rail for local development. The x402 rail is implemented and tested against a fake facilitator, a simulated chain, and the reference `@x402/core`, not yet against a real facilitator or chain. See [x402](/docs/rails/x402). # Access policies (/docs/concepts/access-policies) Policies run in order for each request. The first decision that is not `skip` wins. | Decision | Meaning | Built-in | | --------- | --------------------------------------------------- | ------------------------ | | `grant` | Let the caller through with no charge | `subscriber({ active })` | | `reserve` | Pay from a balance: reserve, then commit or release | `credits({ balance })` | | `pay` | Require a payment on a rail | `payPerCall()` | | `skip` | Let the next policy decide | — | ```ts toll.price("$0.01", { access: [subscriber({ active }), credits({ balance }), payPerCall()], }); ``` * Omit `access` to require payment from everyone. * If `access` is set and nothing grants, reserves, or asks for payment, the request is denied with `403`. * Policies receive the normalized [context](/docs/concepts/requirements#context) — the Web `Request`, the authenticated `principal`, and MCP details — never a framework object. Tollstile evaluates the pricing and access policy you define; it does not decide what your service should cost. # Authorizations and charges (/docs/concepts/authorizations-and-charges) ## Authorization [#authorization] What a payer authorized, created when a rail verifies a proof. * **single** — an x402 payment or an MPP charge. At most one charge that was not released. If the handler failed and the charge was released, the same proof can be presented again. * **reusable** — an L402 credential, a KYAPay token, an MPP session, or a credit account. Many charges until the `limit` is consumed or the authorization expires. Authorizations are keyed by rail and proof, so presenting the same proof finds the same authorization. That is replay protection for single-use proofs and reuse for reusable ones. ## Charge [#charge] One economic effect against an authorization, tracked on two independent axes. ```txt payment reserved ──► settling ──► settled ──► refund_pending ──► refunded │ │ ▲ │ ▼ ▼ │ ▼ released unknown ◄────────────────────┘ resolved by lookup │ ▼ failed fulfillment pending ──► running ──► completed │ ▼ failed ``` * Creating a charge **reserves** its amount on the authorization atomically. Settling commits it; releasing returns it. * Every provider call is preceded by a write (`settling`, `refund_pending`) and followed by one. * A timeout or ambiguous answer becomes `unknown` and is resolved only by asking the provider. * The payment axis says whether money moved; the fulfillment axis says whether the service exists. Reconciliation uses both. ## A paid call, step by step [#a-paid-call-step-by-step] | Step | Charge | | ------------------------------------------------- | -------------------------------------------------------------------------- | | Proof verified, capacity reserved, handler starts | `reserved / running` | | Handler succeeds | `settling / completed` | | Provider confirms | `settled / completed` | | — or handler fails | `released / failed` | | — or provider rejects settlement | `failed / completed`, the output is withheld and a fresh `402` is sent | | — or provider does not answer | `unknown / completed`, the output is served and reconciliation resolves it | # Flows (/docs/concepts/flows) | Flow | Order | Handler fails | Typical rails | | --------------- | ----------------------------------- | ----------------------- | ------------------------------------------------------------------ | | `authorization` | reserve · run · complete · settle | release — nothing moved | x402, MPP Tempo charge, KYAPay, L402, credits | | `upfront` | reserve · settle · run · complete | refund | MPP Stripe charge (captured before the handler), MPP Tempo session | | `escrow` | settle deposit · run · settle final | refund the deposit | Planned — refused in this version | * Each rail declares the flows it supports. A route without `flow` uses the first the rail supports, preferring `authorization`, where the payer is charged only for work that ran. * `upfront` requires a rail that can refund. * A `flow` set on a route applies to every rail on it. Leave it out on routes that mix rails with different flows, such as MPP Stripe and x402. * **Paid at verification.** Some payments have already moved when the rail verifies them, such as an MPP Tempo push transfer. Core records them as `upfront`, settled before the handler. If the handler fails and the rail cannot refund, the charge stays `settled/failed`, an `error` event with `REFUND_REJECTED` asks you to refund outside Tollstile, and reconciliation leaves it alone. * Variable prices (`upTo`) require `authorization`. * Choose explicitly with `toll.price("$0.05", { flow: "upfront" })`. Unsupported combinations fail where the route is defined. # Guarantees (/docs/concepts/guarantees) Stating limits is part of being trustworthy. This page is the contract. ## Tollstile guarantees [#tollstile-guarantees] * A protected handler never runs unless an access policy grants access, a balance reservation succeeds, or a payment proof has been verified against the quoted or configured price. * Retries, replays, and crash recovery do not produce duplicate settlements or duplicate refunds. Every rail must be able to look up a charge at its provider; rails that cannot are refused at startup. * Every charge transition is recorded in your ledger before its effect is acknowledged. * Ambiguous outcomes are recorded as `unknown` and surfaced for reconciliation. * A single-use quote pays only for the request it priced. * When settlement after the handler is rejected, adapters withhold the output and send a fresh `402`. ## Tollstile does not guarantee [#tollstile-does-not-guarantee] * That your handler executes exactly once. * That a response reaches the client after it is sent. * That costs your handler incurred before failing are recoverable. * The behavior, availability, or finality of a rail's provider. * A refund for a payment that moved during verification on a rail that cannot refund. Tollstile records it as settled with failed fulfillment and reports it; you refund the payer yourself. ## Fail closed [#fail-closed] A failure while verifying access always denies the request: `402` for payment problems, `503` for infrastructure failures. There is no code path where an exception results in serving the protected resource. ## Never takes custody [#never-takes-custody] Tollstile verifies proofs, asks the rail's provider to settle, refund, or release, and records the outcome. It never holds balances, never routes funds through its own accounts, and never converts assets. # Ledger (/docs/concepts/ledger) The ledger is the merchant's operational record. The payment network or provider is the final authority on whether money moved; [reconciliation](/docs/guides/reconciliation) keeps the two in agreement. | Record | Purpose | | -------------- | ---------------------------------------------------------------------- | | Authorizations | What each payer authorized, with limit, reserved, and consumed amounts | | Charges | Each economic effect, with payment and fulfillment states | | Transitions | Every state change, for audit | | Claims | Single-use keys such as nonces | ## Guarantees a ledger must provide [#guarantees-a-ledger-must-provide] * `createCharge` reserves capacity atomically and refuses a second charge on a single-use authorization that was not released. * `transitionCharge` is compare-and-set on both axes and updates reserved and consumed amounts in the same step. * An authorization holds one currency: a charge in another is refused with `CURRENCY_MISMATCH`. * Amounts are integers between `0` and `2^63 − 1` micros. Nothing is stored as a float. ## Ledgers [#ledgers] | Ledger | Use | Status | | ----------------------------------------------- | ------------------------------------------------------ | --------------------------------------------- | | `memoryLedger()` | Tests and local development; one process | Implemented | | [`@tollstile/postgres`](/docs/ledgers/postgres) | Bring your own client: `pg`, postgres.js, Neon, PGlite | Implemented; conformance suite on PGlite | | [`@tollstile/sqlite`](/docs/ledgers/sqlite) | node:sqlite, better-sqlite3, bun:sqlite, Cloudflare D1 | Implemented; conformance suite on node:sqlite | All three run one conformance suite. Rail evidence needed to settle after a crash, such as a signed payload, may sit in an authorization's data until the charge is final; core then calls `replaceAuthorizationData` with the rail's redacted data. There is no Tollstile account, no required dashboard, and no telemetry. # Quotes (/docs/concepts/quotes) Every `402` carries a **quote**: the resource, the price, whether it is a maximum, an offer per rail, a nonce, and an expiry. It is serialized as a compact token and signed with your `secret`. ```json { "error": "payment_required", "price": "$0.04", "quote": "eyJ2IjoxLC….x9Q…", "expiresAt": "2026-09-15T12:05:00.000Z", "accepts": [{ "rail": "x402", "asset": { "code": "USDC", "network": "eip155:8453", "scale": 6 }, "amount": "40000", "flow": "authorization" }] } ``` ## Why quotes exist [#why-quotes-exist] * **The price charged is the price the payer saw.** Dynamic prices can change between the `402` and the retry; the proof carries the quote back and Tollstile charges the quoted price. * **Nothing is written for unpaid requests.** Quotes are verified by signature, so the ledger only grows when someone actually pays. * **A quote pays only for the request it priced.** It commits to the method, path, query, and body (or MCP tool arguments), so a cheap quote cannot be spent on a larger request. * **Evidence can bind to a request.** The quote's `nonce` lets protocols such as AP2 bind a user mandate to this exact offer. ## How rails carry a quote [#how-rails-carry-a-quote] Each rail puts the quote token inside its own protocol, where the payer's client echoes it: x402 in the requirement's `extra`, MPP in the challenge's `opaque`, L402 in a macaroon caveat, the test rail as `quote=`. Rails that cannot carry one declare `quotes: false` and serve fixed-price routes only. ## Validation [#validation] A quote is honored only if its signature matches a configured secret, it has not expired, it was issued for the same resource, and the retried request matches its **commitment**. Rotate secrets by listing the new one first: `secret: [next, previous]`. ## Request commitment [#request-commitment] | `commit` | The quote is bound to | Default for | | --------------------- | ------------------------------------------------------------------------------------------ | -------------------------------- | | `"request"` | method, resource, path and query, the exact body bytes; on MCP, the tool and its arguments | dynamic prices | | `"route"` | method and resource | fixed prices | | `(context) => string` | method, resource, and the value you return | bodies that clients re-serialize | A mismatch returns `402` with `reason: "quote_mismatch"` and a fresh quote, before anything is written. Reusable authorizations are the exception: they pay each request's current price against their limit, so they are not held to one request. See [Dynamic pricing](/docs/guides/dynamic-pricing). # Rails (/docs/concepts/rails) A **rail** is how a payment is made and proven. It turns a price into an **offer** in its own asset, issues the protocol's challenge, verifies proofs, and settles, refunds, releases, and looks up charges through its provider. ## Rails are not policies [#rails-are-not-policies] A subscription is not a payment protocol, and x402 is not a pricing model. **Rails** decide how a payment is made; **access policies** decide whether a caller pays. See [Access policies](/docs/concepts/access-policies). ## Capabilities [#capabilities] Rails genuinely differ. Each declares what it can do instead of pretending to be identical. | Capability | Meaning | | -------------------------- | --------------------------------------------------------- | | `flows` | Which [flows](/docs/concepts/flows) it supports | | `authorization` | `single` or `reusable` proofs | | `variableAmount` | Can settle less than the authorized maximum | | `quotes` | Carries a signed quote through its protocol | | `refund` · `partialRefund` | Can return settled money | | `lookup` | Can ask its provider what happened to a charge — required | A rail without `lookup` is refused: without it, an unknown outcome could only be guessed. ## When a provider is down [#when-a-provider-is-down] * **While issuing a challenge** (a Lightning node cannot create an invoice), that rail is left out of the `402`. If no rail can offer, the answer is `503 payment_unavailable`. * **While verifying**, the request gets `503` and the handler does not run. * **While settling or refunding**, the charge becomes `unknown` and [reconciliation](/docs/guides/reconciliation) asks the provider later. ## Payer evidence [#payer-evidence] Some proofs must be kept to settle after a crash, such as a signed x402 payload. A rail keeps them in the authorization's data only until the charge is final, then `redact` drops them. Evidence never appears in logs, errors, events, or receipts. ## Price and asset [#price-and-asset] A route is priced in a currency; a rail settles in an asset. The rail's offer states the asset, network, integer amount, and the basis of conversion — `par` for a USD stablecoin configured as USD, or `rate` for a merchant-supplied rate. Tollstile never converts currencies on its own. ## Available rails [#available-rails] | Rail | Authorization | Flows | Verification status | | ------------------------------------- | ------------------ | -------------------------------------------- | ----------------------------------------------------------------------------------------- | | [Test rail](/docs/rails/test) | single or reusable | `authorization`, `upfront` | Implemented; local only | | [x402](/docs/rails/x402) exact · upto | single | `authorization` | Tested against a fake facilitator, a simulated chain, and `@x402/core`; not verified live | | [MPP](/docs/rails/mpp) Stripe charge | single | `upfront` | Tested against an in-memory Stripe; not verified live | | [MPP](/docs/rails/mpp) Tempo charge | single | `authorization` (push: paid at verification) | Tested against a fake node; not verified live | | [MPP](/docs/rails/mpp) Tempo session | reusable | `upfront` | Experimental | | [L402](/docs/rails/l402) | reusable | `authorization` | Tested against macaroon vectors and a fake LND; not verified live | | [KYAPay](/docs/rails/kyapay) | reusable | `authorization` | Tested against a fake Skyfire; not verified live | # Requirements (/docs/concepts/requirements) ```ts toll.price("$0.40", { require: [ limit({ perPayer: "100/hour", spendPerDay: "$20" }), when(amountOver("$5"), strongerCheck), ], }); ``` A requirement receives: | Field | Use | | --------- | ------------------------------------------------- | | `context` | The normalized request context | | `price` | The price being charged | | `payer` | The rail payer or the policy account | | `quote` | The quote the proof carried, with a fresh `nonce` | | `ledger` | Read access for limits | | `claims` | A single-use store for nonces and replay windows | | `now` | The injected clock | | `signal` | Aborted when the provider timeout elapses | and returns `{ ok: true }` or `{ ok: false, status: 402 | 403 | 429 | 503, reason }`. When evidence cannot be checked right now, for example an agent's key directory is unreachable, answer `503` or throw `TollstileError` with `PROVIDER_UNAVAILABLE` or `PROVIDER_TIMEOUT`. Tollstile answers `503 requirement_unavailable`, so a temporary outage never looks like a permanent `403`. ## Context [#context] ```ts type Context = { transport: "http" | "mcp"; request: Request | null; mcp: { tool: string; arguments: Json; meta: JsonObject; clientCapabilities: JsonObject } | null; principal: { id: string } | null; resource: string; requestId: string; extras: unknown; // the framework object, as an escape hatch }; ``` ## Built-in and planned [#built-in-and-planned] | Requirement | Package | Status | | --------------------------------------------------------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------- | | `limit()`, `payers()`, `when()` | `tollstile` | Implemented | | [`verifiedAgent()`](/docs/guides/verified-agents-only) — Web Bot Auth HTTP message signatures | `@tollstile/web-bot-auth` | Implemented; tested against RFC 9421 and draft vectors, not verified against a live agent | | `userMandate()` — AP2 Payment Mandates bound to the quote nonce | `@tollstile/ap2` | Experimental: AP2 defines no carrier for mandates on API or MCP calls | A requirement that cannot reach its evidence answers `503`: `verifiedAgent()` returns `503 directory_unavailable` when an agent's key directory is unreachable. Tollstile verifies identity and authorization evidence. It never issues identities. # Accept MPP payments (/docs/guides/accept-mpp-payments) Goal: `GET /report` costs $1.00 and accepts two MPP methods on the same route: Stripe `charge` and Tempo `charge`. The same price also guards an MCP tool. ## Prerequisites [#prerequisites] * Node.js 22.18 or later, which runs TypeScript files directly. On older versions, run the files with `npx tsx`. * **Stripe:** a Stripe account that can use Shared Payment Tokens, its test secret key, and your Business Network Profile id (`profile_…`). * **Tempo:** a receiving address, a TIP-20 token address (for example pathUSD), and a JSON-RPC URL — `https://rpc.moderato.tempo.xyz` (chain `42431`) for the Moderato testnet. * Two secrets of 32+ random characters: one for Tollstile quotes, one for MPP challenge ids. ```bash npm install tollstile @tollstile/hono hono @hono/node-server ``` ## 1. Build it on the test rail [#1-build-it-on-the-test-rail] MPP's two methods move money at different times, and the test rail can play both: | Rail | Flow | When money moves | Handler fails | | ------------- | --------------- | ---------------------------------------------------------------------- | --------------------- | | `mppStripe()` | `upfront` | Before the handler: confirming a PaymentIntent captures immediately | Refunded | | `mppTempo()` | `authorization` | After the handler: the signed transaction is broadcast only on success | Nothing was broadcast | ```ts title="server.ts" import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { tollstile } from "@tollstile/hono"; import { createTollstile, memoryLedger, testRail } from "tollstile"; const toll = createTollstile({ rails: [testRail()], ledger: memoryLedger(), onEvent: (event) => { if (event.type === "charge.moved") console.log(event.charge.flow, `${event.charge.payment}/${event.charge.fulfillment}`); }, }); const app = new Hono(); // Like Tempo charge: settle after the handler succeeded. app.get("/report", tollstile(toll.price("$1.00")), (c) => c.json({ report: "…" })); // Like Stripe charge: settle first, refund if the handler fails. app.get("/report-upfront", tollstile(toll.price("$1.00", { flow: "upfront" })), (c) => c.json({ report: "…" })); app.get("/broken-upfront", tollstile(toll.price("$1.00", { flow: "upfront" })), (c) => c.json({ error: "failed" }, 500)); serve({ fetch: app.fetch, port: 3000 }); ``` ```bash node server.ts curl -i -H "Payment: test" localhost:3000/report # authorization: settling → settled curl -i -H "Payment: test" localhost:3000/report-upfront # upfront: settled before the handler runs curl -i -H "Payment: test" localhost:3000/broken-upfront # upfront: settled, then refund_pending → refunded ``` ## 2. Switch to MPP [#2-switch-to-mpp] ```bash npm install @tollstile/mpp ``` ```ts title="server.ts" import { mppStripe, mppTempo } from "@tollstile/mpp"; const toll = createTollstile({ rails: [ mppStripe({ realm: "api.example.com", secret: process.env.MPP_SECRET!, // binds challenge ids; a list rotates secretKey: process.env.STRIPE_SECRET_KEY!, networkId: process.env.STRIPE_NETWORK_ID!, // profile_… }), mppTempo({ realm: "api.example.com", secret: process.env.MPP_SECRET!, rpcUrl: "https://rpc.moderato.tempo.xyz", chainId: 42431, recipient: process.env.TEMPO_RECIPIENT!, token: { address: "0x20c0000000000000000000000000000000000000", code: "pathUSD" }, denomination: "USD", }), ], ledger: memoryLedger(), // use a database ledger in production secret: process.env.TOLLSTILE_SECRET!, }); setInterval(() => void toll.reconcile(), 60_000); app.get("/report", tollstile(toll.price("$1.00")), (c) => c.json({ report: "…" })); ``` * Do not set `flow` on a route that uses both rails. A route's `flow` applies to every rail; without it, each rail uses its own. * **Stripe offers nothing below its minimum charge** (USD $0.50) or for sub-cent amounts. A route priced at `$0.01` with only `mppStripe()` answers a `402` with no offers. Price Stripe routes at $0.50 or more, or put another rail next to it. * `upTo()` prices are refused at startup with either rail: neither supports variable amounts. ## 3. Verify over HTTP [#3-verify-over-http] ```bash curl -i localhost:3000/report ``` ```txt HTTP/1.1 402 Payment Required www-authenticate: Payment id="VNT8…", realm="api.example.com", method="stripe", intent="charge", request="eyJhbW91bnQiOiIxMDAi…", expires="…", opaque="eyJ0b2xsc3RpbGVfcXVvdGUi…" www-authenticate: Payment id="…", realm="api.example.com", method="tempo", intent="charge", request="…", expires="…", opaque="…" ``` The JSON body's `accepts[].details` holds each challenge as an object. A credential echoes one challenge and adds the method's payload, base64url-encoded in `Authorization: Payment`. **Stripe, test mode.** Create a Shared Payment Token with `POST /v1/test_helpers/shared_payment/granted_tokens` (`payment_method=pm_card_visa`, usage limits covering $1.00, and a preview `Stripe-Version`), then pay: ```ts title="pay-stripe.ts" const url = process.argv[2] ?? "http://localhost:3000/report"; const unpaid = await fetch(url); const { accepts } = (await unpaid.json()) as { accepts: { rail: string; details: object }[] }; const challenge = accepts.find((offer) => offer.rail === "mpp-stripe")?.details; if (challenge === undefined) throw new Error("No mpp-stripe offer: is the price at least Stripe's minimum?"); const credential = Buffer.from(JSON.stringify({ challenge, payload: { spt: process.env.SPT } })).toString("base64url"); const paid = await fetch(url, { headers: { authorization: `Payment ${credential}` } }); console.log(paid.status, paid.headers.get("payment-receipt"), await paid.text()); ``` ```bash SPT=spt_… node pay-stripe.ts ``` Expect `200` and a `payment-receipt` header, and in the Stripe dashboard a `succeeded` PaymentIntent with `metadata.challenge_id`. If Stripe rejects the token parameter, set `sptParameter: "payment_method_data[shared_payment_granted_token]"`. `npx mppx@latest validate http://localhost:3000/report` checks the challenge format. **Tempo, Moderato.** Pay the challenge with the `mppx` client in pull mode. Expect `200`, and the transaction hash from the receipt on the Tempo explorer, sent with `transferWithMemo` and the challenge's `memo`. ## 4. The same price on an MCP tool [#4-the-same-price-on-an-mcp-tool] ```bash npm install @tollstile/mcp @modelcontextprotocol/sdk ``` ```ts title="mcp-server.ts" import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { paidTool } from "@tollstile/mcp"; const server = new McpServer({ name: "reports", version: "1.0.0" }); paidTool(server, "report", { description: "Today's report" }, toll.price("$1.00"), () => ({ content: [{ type: "text", text: "…" }], })); ``` A client that declares `capabilities.experimental.payment` receives MPP's JSON-RPC error `-32042` with the challenges, and retries with the credential in `_meta`: ```ts title="mcp-client.ts" import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { McpError } from "@modelcontextprotocol/sdk/types.js"; const client = new Client({ name: "agent", version: "1.0.0" }, { capabilities: { experimental: { payment: {} } } }); // await client.connect(transport); try { await client.callTool({ name: "report" }); } catch (error) { if (!(error instanceof McpError) || error.code !== -32042) throw error; const { challenges } = error.data as { challenges: { method: string }[] }; const challenge = challenges.find((candidate) => candidate.method === "stripe"); const result = await client.callTool({ name: "report", _meta: { "org.paymentauth/credential": { challenge, payload: { spt: process.env.SPT } } }, }); console.log(result._meta?.["org.paymentauth/receipt"]); // { status: "success", method: "stripe", reference: "pi_…", … } } ``` A client that does not declare the capability gets an `isError` result with Tollstile's denial body in `_meta["tollstile/payment-required"]`. The Stripe and Tempo charge rails are tested against in-process fakes and published vectors (mppx's challenge-id vectors, RFC 8785), never against Stripe or a Tempo node. The MCP rendering is tested against fakes shaped like the MPP MCP transport, not `mppx`. `mppTempoSession()` (the Tempo `session` intent) is **experimental**. See [MPP](/docs/rails/mpp). ## When things fail [#when-things-fail] | What happens | Stripe (`upfront`) | Tempo (`authorization`) | | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | No or malformed credential, wrong realm, expired or tampered challenge | `402` with fresh challenges | `402` with fresh challenges | | Credential replayed | `402`; no second PaymentIntent | `402`; no second transfer | | Stripe declines, before the handler | `402` with `reason: "payment_rejected"`; the handler does not run | — | | Stripe does not answer, before the handler | `503`; the handler does not run; the charge is `unknown` until reconciliation looks it up | — | | Handler throws or answers `400`+ | Refunded through Stripe | Released; nothing is broadcast | | Broadcast refused after the handler | — | Output withheld; fresh `402` with `reason: "settlement_rejected"` | | Broadcast answer lost | — | Output served without a receipt; `unknown` until the receipt is found or `validBefore` passes | With Tempo, the payer can spend the nonce or balance between verification and broadcast. The handler has then run unpaid, the charge ends `failed/completed`, and `onEvent` reports `SETTLEMENT_REJECTED`. Push mode (`modes: ["pull", "push"]`) accepts transfers the payer already broadcast. Those payments moved before the handler, and the Tempo rail cannot refund: a failed handler leaves the charge `settled/failed` for you to refund yourself. ## Next [#next] # Add prepaid credits (/docs/guides/add-credits) Use `credits()` when callers top up a balance and each call draws from it. ```ts import { credits, memoryBalance, payPerCall } from "tollstile"; import { tollstile } from "@tollstile/hono"; const balance = memoryBalance({ acct_1: "$5.00" }); // implement Balance on your database in production app.post( "/v1/generate", tollstile( toll.price("$0.02", { access: [credits({ balance }), payPerCall()] }), { principal: (c) => (c.get("user") ? { id: c.get("user").id } : null) }, ), generate, ); ``` * Signed-in callers with enough credit pass; anyone else is asked to pay per call. * The account defaults to the authenticated principal's id. Pass `account: (context) => …` to choose another. ## Reserve, commit, release [#reserve-commit-release] A charge is recorded in the ledger before the balance is touched: 1. The price is **reserved** on the balance. 2. The handler runs. 3. On success the reservation is **committed**; on failure it is **released**. If the process dies in between, [reconciliation](/docs/guides/reconciliation) asks the balance what happened and commits or releases — credits are never lost or spent twice. ## Implement `Balance` on your database [#implement-balance-on-your-database] ```ts import type { Balance } from "tollstile"; export const balance: Balance = { async reserve(account, amount, key) { // In one transaction: if a reservation with `key` exists, return "reserved". // Otherwise, if available >= amount, subtract and insert (key, account, amount, "reserved"). return "reserved"; // or "insufficient" }, async commit(key) { /* reserved → committed */ }, async release(key) { /* reserved → released, and add the amount back */ }, async status(key) { return "committed"; /* reserved | committed | released | none */ }, }; ``` Every method must be idempotent by `key`. # Add pay-per-call pricing to an API (/docs/guides/add-pay-per-call-pricing) Use this when you want each call to a route to cost a fixed amount, for example $0.05 per request. ## 1. Create one Tollstile instance [#1-create-one-tollstile-instance] ```ts title="toll.ts" import { createTollstile, memoryLedger, testRail } from "tollstile"; export const toll = createTollstile({ rails: [testRail()], // replace with live rails in production ledger: memoryLedger(), // replace with a database ledger in production }); ``` Create it once per process and import it wherever routes are defined. ## 2. Wrap the route [#2-wrap-the-route] ```ts title="server.ts" import { Hono } from "hono"; import { tollstile } from "@tollstile/hono"; import { toll } from "./toll"; const app = new Hono(); app.get("/weather", tollstile(toll.price("$0.05")), (c) => c.json({ forecast: "clear" })); ``` `toll.price()` validates the route against every rail where it is defined, so a misconfiguration fails at startup. ## 3. What callers see [#3-what-callers-see] * Without payment: `402 Payment Required` with `price`, a signed `quote`, and an offer per rail in `accepts`. * With a valid payment: your handler runs, the charge settles after it succeeds, and the response carries a receipt header. * If your handler throws or answers `400` or above: the reservation is released and nothing is charged. ## 4. Price several routes [#4-price-several-routes] ```ts app.get("/weather", tollstile(toll.price("$0.01")), weather); app.post("/v1/generate", tollstile(toll.price("$0.05")), generate); app.post("/v1/render", tollstile(toll.price(upTo("$0.50"))), render); // see Charge for usage ``` ## Checklist for production [#checklist-for-production] * Pass `secret` from your secret store when using live rails. * Use a database ledger so charges survive restarts. * Run [`toll.reconcile()`](/docs/guides/reconciliation) on a schedule. * Name routes with path parameters explicitly: `toll.price("$0.05", { resource: "GET /users/:id" })`. # Add spend limits for agents (/docs/guides/add-spend-limits) ```ts import { limit, payers } from "tollstile"; const guarded = toll.price("$0.40", { require: [ limit({ perPayer: "100/hour", spendPerDay: "$20" }), payers({ deny: ["0xBAD…"] }), ], }); ``` * `limit()` counts charges from the ledger that were not released, failed, or refunded. Concurrent requests can briefly exceed a limit by the number in flight. * `payers()` matches payer ids case-insensitively (EVM addresses differ only by checksum casing). * Denials are `429` for limits and `403` for payer rules, and nothing is reserved. ## Only for expensive calls [#only-for-expensive-calls] ```ts import { amountOver, when } from "tollstile"; toll.price("$10", { require: [when(amountOver("$5"), strongerCheck)] }); ``` ## Write your own requirement [#write-your-own-requirement] ```ts import type { Requirement } from "tollstile"; const businessHours: Requirement = { name: "business-hours", async check({ now }) { const hour = now.getUTCHours(); return hour >= 9 && hour < 17 ? { ok: true } : { ok: false, status: 403, reason: "closed" }; }, }; ``` Requirements also receive `claims`, a single-use store for nonces, and the `quote`, which carries a fresh `nonce` for evidence bound to this request. # Let subscribers through without paying (/docs/guides/add-subscriptions) ```ts import { payPerCall, subscriber } from "tollstile"; import { tollstile } from "@tollstile/hono"; const subscribers = subscriber({ active: async (principal) => plans.isActive(principal.id), }); app.get( "/v1/search", tollstile(toll.price("$0.01", { access: [subscribers, payPerCall()] }), { principal: (c) => (c.get("user") ? { id: c.get("user").id } : null), }), search, ); ``` * Policies run in order. The first that grants access wins; `payPerCall()` asks for payment. * Callers without a principal are skipped by `subscriber()`. * If `access` is set and no policy grants access or asks for payment, the request is denied with `403`. * Subscriber access records no charge — there is no economic effect. Combine with credits: `access: [subscribers, credits({ balance }), payPerCall()]`. # Charge for MCP tool calls (/docs/guides/charge-for-mcp-tools) Goal: an MCP server where `forecast` costs $0.01 per call, free tools stay free, and a failed call costs nothing. ## Prerequisites [#prerequisites] * Node.js 22.18 or later, which runs TypeScript files directly. On older versions, run the files with `npx tsx`. * An MCP server built with `@modelcontextprotocol/sdk` 1.23 or later. * For the x402 step: a receiving address on Base Sepolia and a JSON-RPC URL. No wallet is needed for the test rail. ```bash npm install tollstile @tollstile/mcp @modelcontextprotocol/sdk zod ``` Runnable example in the repository: [`examples/mcp`](https://github.com/tollstile/tollstile/tree/main/examples/mcp). ## 1. Price a tool on the test rail [#1-price-a-tool-on-the-test-rail] ```ts title="server.ts" import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { paidTool } from "@tollstile/mcp"; import { createTollstile, memoryLedger, testRail } from "tollstile"; import { z } from "zod"; const toll = createTollstile({ rails: [testRail()], ledger: memoryLedger(), // stdout carries the MCP protocol, so log to stderr. onEvent: (event) => { if (event.type === "charge.moved") { console.error(`${event.charge.id}: ${event.charge.payment}/${event.charge.fulfillment}`); } }, }); const server = new McpServer({ name: "weather", version: "1.0.0" }); // A free tool: registered as usual, never gated. server.registerTool("ping", { description: "Checks the server is up" }, () => ({ content: [{ type: "text", text: "pong" }], })); // A paid tool: same config as registerTool, plus a price. paidTool( server, "forecast", { description: "Tomorrow's forecast for a city", inputSchema: { city: z.string() } }, toll.price("$0.01"), ({ city }, { payment }) => ({ content: [{ type: "text", text: `${city}: clear (paid via ${payment.via})` }], }), ); await server.connect(new StdioServerTransport()); ``` `paidTool(server, name, config, gate, handler, options?)` registers the tool with `McpServer.registerTool` and puts the gate in front of the handler. The handler receives the validated arguments and the SDK's `extra`, plus `payment`. ## 2. Call it from a client [#2-call-it-from-a-client] This script plays the agent: it calls the tool, reads the payment requirement, and pays with the quote. ```ts title="client.ts" import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; const client = new Client({ name: "test-agent", version: "1.0.0" }); await client.connect(new StdioClientTransport({ command: "node", args: ["server.ts"] })); // 1. Call without paying. const unpaid = await client.callTool({ name: "forecast", arguments: { city: "Oslo" } }); const required = unpaid._meta?.["tollstile/payment-required"] as { price: string; quote: string }; console.log("unpaid:", unpaid.isError, required.price); // 2. Pay with the quote the server offered. const paid = await client.callTool({ name: "forecast", arguments: { city: "Oslo" }, _meta: { "tollstile/test-payment": `test quote=${required.quote}` }, }); console.log("paid:", paid.content, paid._meta); await client.close(); ``` ```bash node client.ts ``` ```txt unpaid: true $0.01 chg_c2fa…: settling/completed chg_c2fa…: settled/completed paid: [ { type: 'text', text: 'Oslo: clear (paid via rail)' } ] { 'tollstile/test-receipt': 'test_settlement_chg_c2fa…' } ``` * The unpaid call returns `isError: true`. Tollstile's full denial body — price, signed quote, and every rail's offer — is in `_meta["tollstile/payment-required"]`. * The paid call runs the handler, settles, and adds the receipt to the result's `_meta`. * `ping` never asks for payment. ## 3. Accept x402 [#3-accept-x402] Replace the test rail with the x402 rail. The tool and handler do not change. ```bash npm install @tollstile/x402 ``` ```ts title="server.ts" import { x402 } from "@tollstile/x402"; const toll = createTollstile({ rails: [ x402({ network: "eip155:84532", // Base Sepolia payTo: process.env.PAY_TO!, denomination: "USD", // 1 USDC = 1 USD, stated explicitly rpcUrl: process.env.RPC_URL!, // e.g. https://sepolia.base.org, used by reconciliation }), ], 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); ``` On Base Sepolia the rail uses the x402.org facilitator by default. On mainnet and every other network, pass `facilitator: { url, headers }`. What changes for the client: | | Test rail | x402 | | ---------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | Payment required | `isError` result, body in `_meta["tollstile/payment-required"]` | `isError` result, `structuredContent` = the x402 `PaymentRequired` (the Tollstile body is still in `_meta`) | | Proof | `_meta["tollstile/test-payment"]` | `_meta["x402/payment"]` | | Receipt | `_meta["tollstile/test-receipt"]` | `_meta["x402/payment-response"]` | An x402 MCP client pays from `structuredContent` and retries with `_meta["x402/payment"]`. If the client also declares `capabilities.experimental.payment` and you add an [MPP rail](/docs/rails/mpp), it receives MPP's JSON-RPC error `-32042` instead. The MCP adapter is tested with the real SDK `McpServer` and `Client`. Its x402 and MPP renderings are tested against fake rails shaped like each protocol's MCP transport, not against `@x402/mcp`, `mppx`, or a live facilitator. ## Verify [#verify] With the test rail, `node client.ts` must print `unpaid: true` and then a result with `tollstile/test-receipt`. The stderr log must show one charge ending `settled/completed`. With x402, call the tool from an x402 MCP client funded with Base Sepolia USDC. The client must pay from `structuredContent`, and the result must carry `_meta["x402/payment-response"]` with a transaction hash you can find on [https://sepolia.basescan.org](https://sepolia.basescan.org). ## When things fail [#when-things-fail] | What happens | Result | | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | | No payment | `isError` result with the payment requirement; the handler does not run | | Tampered or expired quote | `isError` result with a fresh quote | | Same proof twice (`test proof=p1`) | The second call is refused: `proof_already_used` | | Handler throws, returns `isError: true`, or returns output that fails `outputSchema` | The reservation is released; nothing is charged. A thrown error is rethrown for the SDK | | Payment provider down during verification | `isError` result with `payment_unavailable`; the handler does not run | | Settlement rejected after the tool ran | The output is withheld and the client gets a fresh payment requirement, reason `settlement_rejected` | | Settlement outcome unknown | The output is returned without a receipt; [reconciliation](/docs/guides/reconciliation) resolves the charge | Retry after a failure with the same proof: a released charge does not consume it. x402's MCP transport puts the payment requirement in `structuredContent`. A client that validates it against the tool's `outputSchema` rejects it. That is the protocol's shape; prefer tools without `outputSchema` for x402. ## Next [#next] # Charge for usage (/docs/guides/charge-for-usage) ```ts import { upTo } from "tollstile"; app.post("/v1/render", tollstile(toll.price(upTo("$0.50"))), async (c) => { const video = await render(await c.req.json()); await storage.put(video); await c.get("payment").fulfill({ amount: costOf(video) }); // e.g. "$0.12" return c.json(video); }); ``` * The payer authorizes up to $0.50. The charge settles the fulfilled amount after the handler. * `fulfill()` marks the moment the service exists. If the handler fails **after** fulfilling, the charge still settles. * A fulfilled amount of `$0` releases the reservation. * If a variable route never calls `fulfill()`, nothing is charged and an `error` event with `FULFILLMENT_MISSING` is emitted. * Variable prices need the `authorization` flow and a rail with `variableAmount`: x402 with `upto` configured, L402, or KYAPay. Every rail on the route must support it; MPP charge rails do not, and a route that mixes them is refused at startup. # Charge per call on Cloudflare Workers (/docs/guides/cloudflare-workers) Goal: a Worker where `GET /weather` costs $0.01 and `GET /reports/:id` costs $0.05, with every charge recorded in a D1 database and reconciled every five minutes. ## Prerequisites [#prerequisites] * A Cloudflare account and `wrangler`. * A D1 database: `npx wrangler d1 create tollstile-ledger`. * For live rails: the prerequisites of the rail you choose, for example [x402](/docs/guides/monetize-an-api-with-x402#prerequisites). ```bash npm install tollstile @tollstile/fetch @tollstile/sqlite ``` Runnable example in the repository: [`examples/cloudflare-workers`](https://github.com/tollstile/tollstile/tree/main/examples/cloudflare-workers). ## 1. Configure the Worker [#1-configure-the-worker] ```jsonc title="wrangler.jsonc" { "name": "paid-api", "main": "src/index.ts", "compatibility_date": "2026-09-01", "d1_databases": [ { "binding": "DB", "database_name": "tollstile-ledger", "database_id": "" } ], "triggers": { "crons": ["*/5 * * * *"] } } ``` Tollstile signs quotes with `secret`. Every isolate must share it, or a quote issued by one fails in another: ```bash openssl rand -base64 32 | npx wrangler secret put TOLLSTILE_SECRET # for wrangler dev, put TOLLSTILE_SECRET=<32+ characters> in .dev.vars ``` ## 2. Apply the ledger schema [#2-apply-the-ledger-schema] `sqliteSchema` is plain DDL. Commit it as a D1 migration: ```bash npx wrangler d1 migrations create DB tollstile node --input-type=module -e "import('@tollstile/sqlite').then((m) => console.log(m.sqliteSchema))" > migrations/0001_tollstile.sql npx wrangler d1 migrations apply DB --local # and without --local for the remote database ``` ## 3. Write the Worker [#3-write-the-worker] ```ts title="src/index.ts" import { paid } from "@tollstile/fetch"; import { sqliteLedger } from "@tollstile/sqlite"; import { createTollstile, testRail } from "tollstile"; type Env = { DB: D1Database; TOLLSTILE_SECRET: string }; function createApp(env: Env) { // D1 has no interactive transactions; the ledger only needs atomic batches. const ledger = sqliteLedger({ execute: async (sql, params) => (await env.DB.prepare(sql).bind(...params).all()).results, transaction: async (statements) => (await env.DB.batch(statements.map(({ sql, params }) => env.DB.prepare(sql).bind(...params)))).map( (result) => result.results, ), }); const toll = createTollstile({ rails: [testRail()], ledger, secret: env.TOLLSTILE_SECRET }); const weather = paid(toll.price("$0.01"), () => Response.json({ forecast: "clear" })); const report = paid(toll.price("$0.05", { resource: "GET /reports/:id" }), (request) => Response.json({ id: new URL(request.url).pathname.split("/")[2] }), ); return { toll, fetch(request: Request): Promise { const { pathname } = new URL(request.url); if (request.method === "GET" && pathname === "/weather") return weather(request); if (request.method === "GET" && /^\/reports\/[^/]+$/.test(pathname)) return report(request); return Promise.resolve(new Response("Not found", { status: 404 })); }, }; } // One instance per isolate, created on first use. let app: ReturnType | undefined; export default { fetch(request, env) { app ??= createApp(env); return app.fetch(request); }, scheduled(_controller, env, ctx) { app ??= createApp(env); ctx.waitUntil(app.toll.reconcile()); }, } satisfies ExportedHandler; ``` * `paid(gate, handler)` turns a priced route into a `(request) => Promise` handler. It does not route; match paths yourself, or use [Hono](/docs/adapters/hono), which also runs on Workers. * The resource is `" "`. Name routes with parameters (`resource: "GET /reports/:id"`) so every id is not its own resource. ## 4. Call it [#4-call-it] ```bash npx wrangler dev curl -i localhost:8787/weather # 402 Payment Required curl -i -H "Payment: test" localhost:8787/weather # 200 OK, payment-receipt: test_settlement_chg_… curl -i -H "Payment: test" localhost:8787/reports/42 # 200 OK ``` Check the ledger: ```bash npx wrangler d1 execute DB --local \ --command "SELECT id, resource, payment, fulfillment, amount_micros FROM tollstile_charges" ``` Both charges must be `settled` / `completed`, with `resource` `GET /weather` and `GET /reports/:id`. ## 5. Switch to a live rail [#5-switch-to-a-live-rail] Replace `testRail()` with a live rail and keep secrets in `wrangler secret put`: ```ts title="src/index.ts" import { x402 } from "@tollstile/x402"; const toll = createTollstile({ rails: [ x402({ network: "eip155:84532", payTo: env.PAY_TO, denomination: "USD", rpcUrl: env.RPC_URL, }), ], ledger, secret: env.TOLLSTILE_SECRET, }); ``` Add `PAY_TO` and `RPC_URL` to `Env`. The packages use Web-standard `fetch` and `crypto.subtle` and import no Node.js modules. The test rail's fake provider lives in memory per isolate. That is fine for trying the flow, but reconciliation of test charges only sees what the same isolate settled. `@tollstile/fetch` is tested with Node 22's `Request` and `Response`, not on the Workers runtime. `@tollstile/sqlite` runs its conformance suite on `node:sqlite` and through an adapter that behaves like D1; the D1 adapter above has not been executed against D1. To verify, run `wrangler dev` with a local database and the steps above. ## When things fail [#when-things-fail] | What happens | Result | | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | Handler returns `400`+ or throws | Released or refunded; a thrown error is rethrown to the runtime | | Settlement rejected | The body is cancelled; a fresh `402` with `reason: "settlement_rejected"` is returned | | Settlement outcome unknown | The response is returned without a receipt; the cron trigger's `reconcile()` resolves it | | A D1 batch fails | The whole batch rolls back; the request errors and nothing is half-written | | A charge is left mid-lifecycle because an isolate was evicted | The next scheduled `reconcile()` releases, settles, or looks it up, once it is older than `olderThanMs` (15 minutes by default) | ## Next [#next] # Dynamic pricing with quotes (/docs/guides/dynamic-pricing) ```ts app.post( "/v1/translate", tollstile(toll.price(async (context) => { const words = await countWords(context.request); return `$${(words * 0.0001).toFixed(4)}`; })), translate, ); ``` 1. A request without payment gets `402` with a signed **quote** for the computed price. 2. The payer retries **the same request** with a proof that carries the quote back. 3. Tollstile charges the **quoted** price, even if the function would now return a different one. Quotes expire after `quoteTtlMs` (5 minutes by default) and are never stored. Dynamic routes require rails that can carry quotes; routes with other rails are refused at startup. ## A quote only pays for the request it priced [#a-quote-only-pays-for-the-request-it-priced] A quote commits to the request it was issued for: method, path and query, and a hash of the exact body bytes. On MCP it commits to the tool name and its arguments. If the retry differs, Tollstile answers `402` with `reason: "quote_mismatch"` and a fresh quote for the new request. Nothing is authorized or charged. So this does not work: ```http POST /v1/translate {"text": "hello"} → 402, quote for $0.0001 POST /v1/translate {"text": ""} → 402 quote_mismatch, quote for $100.00 Payment: …quote for $0.0001… ``` Your price function and handler both read the body normally. Tollstile hashes a copy. ### Clients that re-serialize the body [#clients-that-re-serialize-the-body] Binding to exact bytes means the retry must send the same bytes. If your clients may reorder keys or add fields that do not affect the price, bind only the fields that do: ```ts toll.price(priceByWords, { commit: async (context) => { const { text, targetLanguage } = await context.request.json(); return JSON.stringify([text, targetLanguage]); }, }); ``` Anything the commitment leaves out can change after quoting, so include every input your price depends on. ### Reusable credentials [#reusable-credentials] Credentials that pay for many requests (L402, KYAPay) are charged each request's own price against their limit. They are never locked to the first request's quote. Tollstile does not decide what your service should cost — it evaluates the price you compute. # Monetize an API with x402 (/docs/guides/monetize-an-api-with-x402) 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 [#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](https://faucet.circle.com), and a Base Sepolia JSON-RPC URL. ```bash npm install tollstile @tollstile/hono hono @hono/node-server ``` Runnable example in the repository: [`examples/hono`](https://github.com/tollstile/tollstile/tree/main/examples/hono). ## 1. Build it on the test rail [#1-build-it-on-the-test-rail] ```ts title="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 }); ``` ```bash node server.ts ``` ## 2. Call it [#2-call-it] ```bash curl -i localhost:3000/weather ``` ```txt HTTP/1.1 402 Payment Required cache-control: no-store content-type: application/json {"error":"payment_required","reason":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",…}]} ``` ```bash curl -i -H "Payment: test quote=eyJ2IjoxLCJpZCI6…" localhost:3000/weather curl -i -X POST -H "Payment: test" localhost:3000/generate ``` ```txt 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 [#3-switch-to-x402] ```bash npm install @tollstile/x402 ``` Replace the instance. Routes and handlers stay the same. ```ts title="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`, an `upTo()` route is refused at startup. * 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 [#verify-with-a-real-client] `curl -i localhost:3000/weather` now returns `402` with a `PAYMENT-REQUIRED` header. `echo
| 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. ```bash npm install @x402/fetch @x402/evm viem ``` ```ts title="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](https://sepolia.basescan.org), `/weather` shows a 0.01 USDC transfer to `payTo`; `/generate` shows 0.03 USDC through the upto proxy. 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 [#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 the facilitator's reason | | 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 `reason: "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 [#next] # Add a paid route to Next.js (/docs/guides/nextjs-paid-route) Goal: `GET /api/reports/[id]` in a Next.js App Router app costs $0.01 per call, with the payment settled before the response is returned. ## Prerequisites [#prerequisites] * A Next.js App Router app (route handlers in `app/**/route.ts`). * For production: a PostgreSQL database reachable from your deployment, and the x402 prerequisites from [Monetize an API with x402](/docs/guides/monetize-an-api-with-x402#prerequisites). ```bash npm install tollstile @tollstile/next ``` `@tollstile/next` has no dependency on `next`; it wraps Web-standard route handlers. Runnable example in the repository: [`examples/nextjs`](https://github.com/tollstile/tollstile/tree/main/examples/nextjs). ## 1. Create one instance [#1-create-one-instance] ```ts title="lib/toll.ts" import { createTollstile, memoryLedger, testRail } from "tollstile"; export const toll = createTollstile({ rails: [testRail()], ledger: memoryLedger() }); ``` ## 2. Wrap the route handler [#2-wrap-the-route-handler] ```ts title="app/api/reports/[id]/route.ts" import { paid } from "@tollstile/next"; import { toll } from "@/lib/toll"; export const GET = paid( toll.price("$0.01", { resource: "GET /api/reports/[id]" }), async (request, { params, payment }) => { const { id } = await params; return Response.json({ id, paidWith: payment.via }); }, ); ``` `paid(gate, handler, options?)` returns a route handler. Your handler receives the request and `{ params, payment }`. Name the resource on routes with dynamic segments. Without it, the resource is `" "`, so every `/api/reports/42` becomes its own resource in your ledger and limits. ## 3. Call it [#3-call-it] ```bash npm run dev curl -i localhost:3000/api/reports/42 # 402 Payment Required, signed quote in the body curl -i -H "Payment: test" localhost:3000/api/reports/42 # 200 OK ``` ```txt HTTP/1.1 200 OK payment-receipt: test_settlement_chg_… {"id":"42","paidWith":"rail"} ``` Run `next build` as well: it type-checks the exported `GET`. ## 4. Go to production [#4-go-to-production] `memoryLedger()` lives in one process. On serverless deployments each invocation may run in a different instance, so use a database ledger. With [Postgres](/docs/ledgers/postgres) on Neon's WebSocket `Pool`: ```bash npm install @tollstile/x402 @tollstile/postgres @neondatabase/serverless ``` ```ts title="lib/toll.ts" import { Pool } from "@neondatabase/serverless"; import { postgresLedger } from "@tollstile/postgres"; import { x402 } from "@tollstile/x402"; import { createTollstile } from "tollstile"; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const ledger = postgresLedger({ query: (sql, params) => pool.query(sql, params), transaction: async (work) => { const client = await pool.connect(); try { await client.query("BEGIN"); const result = await work((sql, params) => client.query(sql, params)); await client.query("COMMIT"); return result; } catch (error) { await client.query("ROLLBACK"); throw error; } finally { client.release(); } }, }); export const toll = createTollstile({ rails: [ x402({ network: "eip155:84532", payTo: process.env.PAY_TO!, denomination: "USD", rpcUrl: process.env.RPC_URL!, }), ], ledger, secret: process.env.TOLLSTILE_SECRET!, // 32+ random characters, the same in every instance }); ``` Apply the schema once, as a migration: see [Applying the schema](/docs/ledgers/postgres#applying-the-schema). Every instance must share `secret`, or a quote issued by one instance fails in another. Run reconciliation on a schedule from a route your scheduler (for example, Vercel Cron) calls: ```ts title="app/api/reconcile/route.ts" import { toll } from "@/lib/toll"; export async function GET(request: Request) { if (request.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`) { return new Response("Unauthorized", { status: 401 }); } return Response.json(await toll.reconcile()); } ``` ## Verify [#verify] 1. With the test rail, the two `curl` commands return `402` with a `quote` in the body, then `200` with a `payment-receipt` header. 2. With x402, `curl -i` returns `402` with a `PAYMENT-REQUIRED` header. Pay with the [reference client script](/docs/guides/monetize-an-api-with-x402#verify-with-a-real-client) and check that `tollstile_charges` has one row in `settled`. The Next.js adapter is tested by calling the exported handler the way Next.js does, including type assignability for static, dynamic, and catch-all routes. It has not been run inside a Next.js application. The x402 rail and the Neon adapter have not been verified against live services. ## When things fail [#when-things-fail] | Handler result | Payment | | ---------------------------- | ---------------------------------------------------------------------------------------- | | A response below `400` | Settled; receipt headers added to a copy of the response | | A response of `400` or above | Released (or refunded on the `upfront` flow) | | Throws or rejects | Released, then the error is rethrown to Next.js | | Settlement rejected | The body is cancelled and a fresh `402` with `reason: "settlement_rejected"` is returned | | Settlement outcome unknown | The response is returned without a receipt; reconciliation resolves the charge | `redirect()` and `notFound()` from `next/navigation` work by throwing, so they count as failures. Return `NextResponse.redirect()` when a redirect is the paid result. ## Next [#next] # Run reconciliation (/docs/guides/reconciliation) When a provider times out or a process dies between steps, a charge can be left `reserved`, `settling`, `unknown`, or `refund_pending`. `toll.reconcile()` resolves them by asking the provider — never by guessing. ```ts const report = await toll.reconcile({ olderThanMs: 15 * 60_000 }); // { examined: 3, resolved: 2, pending: 1 } ``` ## Schedule it [#schedule-it] ```ts title="Node" setInterval(() => void toll.reconcile(), 60_000); ``` ```ts title="Cloudflare Workers" export default { fetch: app.fetch, scheduled: (_event, _env, ctx) => ctx.waitUntil(toll.reconcile()), }; ``` ## What it does [#what-it-does] | Charge | Action | | ------------------------------- | ------------------------------------------------------------------- | | reserved, handler not completed | Release — the service may not exist | | reserved, handler completed | Settle | | settling or unknown | Look up at the provider; record what happened, or retry, or release | | settled, handler not completed | Refund — money moved before the service was confirmed | | refund pending or unknown | Look up; record the refund or retry it | `olderThanMs` must exceed your slowest handler, so reconciliation never acts on a request still running. Charges it cannot resolve stay pending and emit `error` events. # Test payment failures (/docs/guides/test-payment-failures) ```ts import { createTollstile, memoryLedger, testRail } from "tollstile"; import { fakeClock } from "tollstile/testing"; const rail = testRail(); const clock = fakeClock(); const toll = createTollstile({ rails: [rail], ledger: memoryLedger({ clock }), clock }); rail.simulate({ settle: "timeout-after-effect" }); // money moved, but the answer was lost // …call a priced route: the charge ends `unknown` rail.simulate({}); clock.advance(60_000); await toll.reconcile({ olderThanMs: 1_000 }); // the charge is `settled`, and rail.effects.settlements is still 1 ``` | Simulation | Effect | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `verify: "unavailable"` | `503`, handler not run | | `verify: "paid"` | The payment moves during verification, like a pushed on-chain transfer: the charge is `settled` before the handler | | `challenge: "unavailable"` | The test rail's offer is left out of the `402`; with no other rail, `503 payment_unavailable` | | `settle: "reject"` | Charge `failed`, `SETTLEMENT_REJECTED` event; the adapter withholds the output and sends a fresh `402` with `reason: "settlement_rejected"` | | `settle: "timeout-before-effect"` | `unknown`; reconciliation retries or releases | | `settle: "timeout-after-effect"` | `unknown`; reconciliation records the settlement once | | `refund: "timeout-after-effect"` | `unknown`; reconciliation records the refund once | | `lookup: "unavailable"` | Reconciliation leaves the charge pending | `rail.effects` counts settlements, refunds, and releases so tests can assert nothing happened twice. `tollstile/testing` also exports `httpContext()` and `mcpContext()` to drive gates without a framework. # Admit only verified agents (/docs/guides/verified-agents-only) Goal: `GET /weather` costs $0.01 **and** only answers agents that sign their requests with a key published by an origin you trust. Everyone else gets `403` before anything is reserved. `verifiedAgent()` answers *who* is calling, not whether they paid. It is a [requirement](/docs/concepts/requirements): it runs after the payer is known and before the ledger is touched, next to any rail or access policy. ## Prerequisites [#prerequisites] * Node.js 22.18 or later, which runs TypeScript files directly. On older versions, run the files with `npx tsx`. * The HTTPS origins of the agents you trust. Each publishes its keys at `https:///.well-known/http-message-signatures-directory`. ```bash npm install tollstile @tollstile/hono @tollstile/web-bot-auth hono @hono/node-server ``` ## 1. Require a signature [#1-require-a-signature] ```ts title="server.ts" import { readFileSync } from "node:fs"; import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { tollstile } from "@tollstile/hono"; import { verifiedAgent } from "@tollstile/web-bot-auth"; import { createTollstile, memoryLedger, testRail } from "tollstile"; const AGENT = "https://agent.example"; const DIRECTORY = `${AGENT}/.well-known/http-message-signatures-directory`; // Local development only: serve the agent's key directory from a file instead of the network. const localDirectory: typeof fetch = (input, init) => String(input) === DIRECTORY ? Promise.resolve( new Response(readFileSync("directory.json"), { headers: { "content-type": "application/http-message-signatures-directory+json" }, }), ) : fetch(input, init); const toll = createTollstile({ rails: [testRail()], ledger: memoryLedger() }); const agentsOnly = toll.price("$0.01", { require: [verifiedAgent({ trust: [AGENT], requireNonce: true, fetch: localDirectory })], }); const app = new Hono(); app.get("/weather", tollstile(agentsOnly), (c) => c.json({ forecast: "clear" })); serve({ fetch: app.fetch, port: 3000 }); ``` In production, drop the `fetch` option: the directory is fetched from the trusted origin, and only origins in `trust` are ever fetched. ## 2. Create an agent key [#2-create-an-agent-key] ```ts title="keys.ts" import { writeFileSync } from "node:fs"; const { publicKey, privateKey } = (await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"])) as CryptoKeyPair; const { kty, crv, x } = await crypto.subtle.exportKey("jwk", publicKey); // What https://agent.example/.well-known/http-message-signatures-directory serves. writeFileSync("directory.json", JSON.stringify({ keys: [{ kty, crv, x }] }, null, 2)); // The agent's private key. Never publish it. writeFileSync("agent-key.json", JSON.stringify(await crypto.subtle.exportKey("jwk", privateKey))); ``` ## 3. Sign a request [#3-sign-a-request] A minimal RFC 9421 signer for the Web Bot Auth profile. Agents normally use a library for this, such as Cloudflare's [`web-bot-auth`](https://github.com/cloudflare/web-bot-auth). ```ts title="agent.ts" import { readFileSync } from "node:fs"; const AGENT = "https://agent.example"; const url = new URL(process.argv[2] ?? "http://localhost:3000/weather"); const privateJwk = JSON.parse(readFileSync("agent-key.json", "utf8")) as JsonWebKey; const key = await crypto.subtle.importKey("jwk", privateJwk, { name: "Ed25519" }, false, ["sign"]); // keyid is the RFC 7638 thumbprint: SHA-256 over the public key's required members, in order. const { crv, kty, x } = privateJwk; const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify({ crv, kty, x }))); const keyid = Buffer.from(digest).toString("base64url"); const created = Math.floor(Date.now() / 1000); const params = `("@authority" "@method" "@path" "signature-agent";key="sig1")` + `;created=${created};expires=${created + 60};keyid="${keyid}";alg="ed25519"` + `;nonce="${crypto.randomUUID()}";tag="web-bot-auth"`; // The signature base: one line per covered component, then the signature parameters. const base = [ `"@authority": ${url.host}`, `"@method": GET`, `"@path": ${url.pathname}`, `"signature-agent";key="sig1": "${AGENT}"`, `"@signature-params": ${params}`, ].join("\n"); const signature = await crypto.subtle.sign("Ed25519", key, new TextEncoder().encode(base)); const response = await fetch(url, { headers: { payment: "test", "signature-agent": `sig1="${AGENT}"`, "signature-input": `sig1=${params}`, signature: `sig1=:${Buffer.from(signature).toString("base64")}:`, }, }); console.log(response.status, await response.text()); ``` ## 4. Verify [#4-verify] ```bash node keys.ts node server.ts ``` ```bash node agent.ts # 200 {"forecast":"clear"} curl -i localhost:3000/weather # 402: payment comes first curl -i -H "Payment: test" localhost:3000/weather # 403: paid, but not signed ``` ```txt HTTP/1.1 403 Forbidden {"error":"requirement_failed","requirement":"verified-agent","reason":"signature_missing"} ``` Run `node agent.ts` a second time and it passes again: each run signs with a fresh nonce. A signature whose covered components do not match the request fails with `403 signature_invalid`; a key missing from the directory fails with `403 key_not_found`. `verifiedAgent()` is tested against RFC 9421 and Web Bot Auth draft test vectors and freshly generated ed25519, P-256, and RSA-PSS keys with fake directories. It has not been verified against a live agent. To check one, trust its origin and call your endpoint with its signer: expect `200`; change one covered header and expect `403 signature_invalid`. ## When things fail [#when-things-fail] | Reason | Status | Meaning | | ------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------ | | `signature_missing`, `signature_malformed`, `tag_missing` | `403` | No usable Web Bot Auth signature | | `signature_expired`, `signature_too_old`, `signature_not_yet_valid` | `403` | Outside `created` / `expires`, `maxAgeMs`, or `clockSkewMs` | | `agent_untrusted` | `403` | The Signature-Agent origin is not in `trust`; nothing was fetched | | `key_not_found`, `algorithm_mismatch`, `signature_invalid` | `403` | Key selection or cryptographic verification failed | | `nonce_missing`, `nonce_replayed` | `403` | Replay protection | | `http_request_required` | `403` | MCP tool calls are never attributed to a signed HTTP request | | `directory_invalid` | `403` | The directory answered, but with a redirect, another non-200 status, or malformed or oversized content | | `directory_unavailable` | `503` | The directory could not be reached and nothing usable is cached. Retry later | Every denial happens before anything is reserved. A nonce is claimed before the charge is created, so a request whose handler failed cannot be retried with the same signature: agents sign each attempt. ## Deployment notes [#deployment-notes] * **Reconstruct the public URL.** `@authority` and `@path` come from `request.url`. Behind a proxy, the adapter must see the URL the agent signed (for example, Express `trust proxy`). * **Ask for more than `@authority`.** A signature over `@authority` alone can be replayed against any path until it expires. Require `@method` and `@path` from your agents, or set `requireNonce: true`. * **A predicate is your SSRF boundary.** `trust: (origin) => boolean` decides which directories are fetched. Web-standard `fetch` cannot block private address ranges for you. | Option | Default | | | -------------- | -------------- | ---------------------------------------------------------------------------------------------- | | `trust` | required | Origins you accept, or `(origin) => boolean` | | `maxAgeMs` | `300000` | Oldest `created` accepted | | `clockSkewMs` | `5000` | Tolerance for `created`, `expires`, and the maximum age | | `requireNonce` | `false` | Reject signatures without a `nonce`; present nonces are always single-use | | `cacheTtlMs` | `3600000` | Longest a directory is reused; a shorter `Cache-Control: max-age` wins, never below one minute | | `timeoutMs` | `3000` | Upper bound for one directory fetch | | `fetch` | global `fetch` | For tests, local development, or egress proxies | ## Next [#next] # Accept x402 payments in Express (/docs/guides/x402-with-express) 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 [#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](https://faucet.circle.com), and a Base Sepolia JSON-RPC URL. ```bash npm install tollstile @tollstile/express express ``` Runnable example in the repository: [`examples/express`](https://github.com/tollstile/tollstile/tree/main/examples/express). ## 1. Build it on the test rail [#1-build-it-on-the-test-rail] ```ts title="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 })`. ```bash 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 ``` ```txt 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 [#2-switch-to-x402] ```bash npm install @tollstile/x402 ``` ```ts title="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](/docs/guides/monetize-an-api-with-x402#3-switch-to-x402). Behind a reverse proxy, set `app.set("trust proxy", …)` so the URL rails see matches the public one. ## Verify [#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](/docs/guides/monetize-an-api-with-x402#verify-with-a-real-client) 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](https://sepolia.basescan.org). 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 [#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 `reason: "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. 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 [#next] # Postgres (/docs/ledgers/postgres) ```bash npm install tollstile @tollstile/postgres pg ``` * **Bring your own client.** You pass two functions, `query` and `transaction`. `pg`, postgres.js, Neon, and PGlite all work. No runtime dependencies. * **Same behavior as `memoryLedger()`.** Both run one conformance suite, including reservation accounting across `reserved → settling → settled → refunded` and `unknown`. * **Concurrency-safe.** Every write to a charge locks its authorization row first, so concurrent requests cannot over-reserve an authorization. Of several requests racing for a single-use authorization, one wins. ## Quick start with pg [#quick-start-with-pg] ```ts title="toll.ts" import pg from "pg"; import { createTollstile, testRail } from "tollstile"; import { postgresLedger, postgresSchema } from "@tollstile/postgres"; const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); await pool.query(postgresSchema); // once, or through your migration tool const ledger = postgresLedger({ query: (sql, params) => pool.query(sql, params), transaction: async (work) => { const client = await pool.connect(); try { await client.query("BEGIN"); const result = await work((sql, params) => client.query(sql, params)); await client.query("COMMIT"); return result; } catch (error) { await client.query("ROLLBACK"); throw error; } finally { client.release(); } }, }); export const toll = createTollstile({ rails: [testRail()], ledger }); ``` ## Applying the schema [#applying-the-schema] `postgresSchema` is a string of plain DDL: four tables, their indexes, and a header comment. Every statement uses `IF NOT EXISTS`, so applying it again is harmless. * **At startup:** `await pool.query(postgresSchema)`. Fine for small deployments. * **With a migration tool** (node-pg-migrate, Drizzle, Prisma, Flyway, sqitch, Supabase): print the DDL once and commit it as a migration. ```bash node --input-type=module -e "import('@tollstile/postgres').then((m) => console.log(m.postgresSchema))" > migrations/001_tollstile.sql ``` * **Another table prefix:** every identifier starts with `tollstile_`, so `postgresSchema.replaceAll("tollstile_", "billing_")` is the DDL for `tablePrefix: "billing_"`. Schema changes in later releases ship as separate, additive migrations. The ledger never alters tables itself. ## Options [#options] | Option | Type | Default | | | ------------- | ------------------------------------ | -------------- | -------------------------------------------------------------------------- | | `query` | `(sql, params) => Promise<{ rows }>` | required | Runs one statement outside a transaction. | | `transaction` | `(work) => Promise` | required | Runs `work(query)` in one interactive transaction on one connection. | | `tablePrefix` | `string` | `"tollstile_"` | Lowercase letters, digits, underscores. Must match the schema you applied. | | `clock` | `{ now(): Date }` | system clock | Decides when claims have expired. Use the same clock as `createTollstile`. | ## Driver adapters [#driver-adapters] `query(sql, params)` runs one statement with `$1, $2, …` parameters and resolves with `{ rows }`, rows keyed by column name. Parameters are always strings or `null`; the ledger casts them in SQL and reads every `bigint`, `jsonb`, and timestamp back as text, so driver type parsers and session time zones never matter. `transaction(work)` runs `work` inside one **interactive** transaction on **one connection**, committing when it resolves and rolling back when it rejects. The ledger takes `SELECT … FOR UPDATE` locks inside it, so it needs the default `READ COMMITTED` isolation. Under `SERIALIZABLE`, concurrent requests fail with serialization errors instead of waiting. ### postgres.js [#postgresjs] ```ts import postgres from "postgres"; const sql = postgres(process.env.DATABASE_URL); const ledger = postgresLedger({ query: async (text, params) => ({ rows: await sql.unsafe(text, params) }), transaction: (work) => sql.begin((tx) => work(async (text, params) => ({ rows: await tx.unsafe(text, params) }))), }); ``` Behind PgBouncer in transaction mode, create the client with `prepare: false`. ### Neon [#neon] Neon's HTTP driver (`neon()`) only runs non-interactive transactions, which cannot hold a row lock while the ledger decides. Use the WebSocket `Pool`, which is `pg`-compatible: ```ts import { Pool } from "@neondatabase/serverless"; const pool = new Pool({ connectionString: env.DATABASE_URL }); // on Workers: per request // then the pg adapter from the quick start ``` ### PGlite [#pglite] ```ts import { PGlite } from "@electric-sql/pglite"; const db = new PGlite("./ledger"); await db.exec(postgresSchema); const ledger = postgresLedger({ query: (sql, params) => db.query(sql, params), transaction: (work) => db.transaction((tx) => work((sql, params) => tx.query(sql, params))), }); ``` ## How each operation stays correct [#how-each-operation-stays-correct] | Operation | Statements | Why it is safe | | -------------------------- | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `openAuthorization` | `INSERT … ON CONFLICT (id) DO NOTHING`, then `SELECT` | The id is derived from rail and proof, so a replayed proof finds the stored row. | | `createCharge` | One transaction: lock the authorization, check it, insert the charge and its first history row, update `reserved` | Checks and reservation happen under the row lock, so concurrent charges on one authorization are serialized. | | `transitionCharge` | One transaction: lock the authorization, compare-and-set on both axes, append history, update `reserved` / `consumed` | The compare-and-set also runs in SQL, so even a writer that skipped the lock cannot overwrite a transition. | | `replaceAuthorizationData` | One `UPDATE` of `data` | Core calls it with the rail's `redact` output when a single-use charge becomes final, to drop evidence such as payer signatures. | | `pendingCharges` | `SELECT` on a partial index | The index condition is generated from core's terminal states, so finished charges are never scanned. | | `spendSince` | `SELECT … GROUP BY currency` on `(payer, created_at)` | Excludes `released`, `failed`, and `refunded`. | | `claim` | `INSERT … ON CONFLICT DO UPDATE … WHERE expires_at <= now RETURNING` | One statement: a live claim is untouched, an expired one is taken over. | ## Tables [#tables] | Table | Holds | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `tollstile_authorizations` | What a payer authorized: rail, kind, `limit`, and the `reserved` / `consumed` totals of its charges. | | `tollstile_charges` | Each economic effect: amount, `payment` × `fulfillment` state, pending operation, settlement and refund references. `version` counts its history rows. | | `tollstile_charge_transitions` | Append-only history. Version 1 is the creation; every transition adds a row in the same transaction. | | `tollstile_claims` | Single-use keys (nonces, replay windows) until `expires_at`. | Money is integer micros (`1 USD = 1,000,000`) in `bigint` with a currency code, never floating point. Amounts outside `0 … 2^63 − 1` are refused with `INVALID_AMOUNT`. An authorization holds one currency: a charge or patched amount in another is refused with `CURRENCY_MISMATCH`. Timestamps are `timestamptz`; JSON is `jsonb`. ```sql -- Revenue settled today, per currency SELECT currency, sum(amount_micros)::numeric / 1000000 AS amount FROM tollstile_charges WHERE payment = 'settled' AND updated_at >= current_date GROUP BY currency; -- Everything that happened to one charge SELECT * FROM tollstile_charge_transitions WHERE charge_id = $1 ORDER BY version; -- Expired claims can be deleted at any time DELETE FROM tollstile_claims WHERE expires_at < now() - interval '1 day'; ``` ## Verification status [#verification-status] Tested: * The shared ledger conformance suite against **PGlite 0.5.8** (PostgreSQL 17 compiled to WASM): every `createCharge` status, single-use busy versus a released retry, reusable capacity, compare-and-set conflicts, accounting through every payment state, currency and amount refusals, `replaceAuthorizationData`, history, `pendingCharges`, `spendSince`, claim expiry, and amounts up to 2^63 − 1. * End-to-end flows through `createTollstile`: quote round-trip, replay refusal, retry after a failed handler, redaction after settlement, settlement timeout reconciled, crash recovery, and credits. * Rollback when a statement fails mid-transaction. Not verified: * **Real lock contention.** PGlite has one connection, so the concurrency tests confirm the outcome but not `FOR UPDATE` waiting between connections. To verify on a real server, run the conformance suite with a `pg.Pool` of 10+ connections and fire 50 concurrent `createCharge` calls at one authorization: one `created` for a single-use authorization, and `reserved_micros` never above `limit_micros`. * **The postgres.js and Neon adapters** above, which were written from their documentation. Run the conformance file (`test/ledger-conformance.ts` in the repository) against them before production use. * PostgreSQL versions other than 17. The schema uses only features available since 9.5. # SQLite and D1 (/docs/ledgers/sqlite) ```bash npm install tollstile @tollstile/sqlite ``` * **Bring your own driver.** You pass two functions, `execute` and `transaction`. No runtime dependencies. * **Same behavior as `memoryLedger()`.** Both run one conformance suite, including reservation accounting across `reserved → settling → settled → refunded` and `unknown`. * **Batch transactions only.** The ledger never reads between the statements of a transaction, so D1, whose transactions are batches, is supported by design. ## Quick start with node:sqlite [#quick-start-with-nodesqlite] ```ts title="toll.ts" import { DatabaseSync } from "node:sqlite"; import { createTollstile, testRail } from "tollstile"; import { sqliteLedger, sqliteSchema } from "@tollstile/sqlite"; const db = new DatabaseSync("ledger.db"); db.exec("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;"); db.exec(sqliteSchema); // once, or through your migration tool const all = (sql: string, params: readonly (string | number | null)[]) => db.prepare(sql).all(...params); const ledger = sqliteLedger({ execute: all, transaction: (statements) => { db.exec("BEGIN IMMEDIATE"); try { const results = statements.map(({ sql, params }) => all(sql, params)); db.exec("COMMIT"); return results; } catch (error) { db.exec("ROLLBACK"); throw error; } }, }); export const toll = createTollstile({ rails: [testRail()], ledger }); ``` ## Applying the schema [#applying-the-schema] `sqliteSchema` is a string of plain DDL: four `STRICT` tables, their indexes, and a header comment. It needs SQLite 3.38 or later. Every statement uses `IF NOT EXISTS`. * **At startup:** `db.exec(sqliteSchema)`. * **With a migration tool, including D1:** print the DDL once and commit it as a migration. ```bash npx wrangler d1 migrations create DB tollstile node --input-type=module -e "import('@tollstile/sqlite').then((m) => console.log(m.sqliteSchema))" > migrations/0001_tollstile.sql npx wrangler d1 migrations apply DB ``` * **Another table prefix:** `sqliteSchema.replaceAll("tollstile_", "billing_")` is the DDL for `tablePrefix: "billing_"`. Schema changes in later releases ship as separate, additive migrations. The ledger never alters tables itself. ## Options [#options] | Option | Type | Default | | | ------------- | ------------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------- | | `execute` | `(sql, params) => rows \| Promise` | required | Runs one statement. | | `transaction` | `(statements) => rows[] \| Promise` | required | Runs the statements atomically in one write transaction, returning each statement's rows in order. | | `tablePrefix` | `string` | `"tollstile_"` | Lowercase letters, digits, underscores. Must match the schema you applied. | | `clock` | `{ now(): Date }` | system clock | Decides when claims have expired. Use the same clock as `createTollstile`. | ## The driver contract [#the-driver-contract] `execute(sql, params)` runs one statement with anonymous `?` parameters and returns its rows as objects keyed by column name, synchronously or as a promise. `transaction(statements)` runs `{ sql, params }` statements in order inside **one write transaction** and returns each statement's rows, in order. All take effect or none do. Parameters are only strings, numbers, and `null`. Money is bound as decimal text, cast to `INTEGER` in SQL, and read back as `TEXT`, so it never passes through a JavaScript number. Timestamps and counts may come back as `number` or `bigint`; a number beyond `Number.MAX_SAFE_INTEGER` is refused. ### better-sqlite3 [#better-sqlite3] ```ts import Database from "better-sqlite3"; const db = new Database("ledger.db"); db.pragma("journal_mode = WAL"); db.exec(sqliteSchema); // better-sqlite3 refuses .all() on statements that return no rows. const all = (sql: string, params: readonly (string | number | null)[]) => { const statement = db.prepare(sql); return statement.reader ? statement.all(...params) : (statement.run(...params), []); }; const ledger = sqliteLedger({ execute: all, transaction: (statements) => db.transaction(() => statements.map(({ sql, params }) => all(sql, params))).immediate(), }); ``` ### bun:sqlite [#bunsqlite] ```ts import { Database } from "bun:sqlite"; const db = new Database("ledger.db"); db.exec("PRAGMA journal_mode = WAL;"); db.exec(sqliteSchema); const all = (sql: string, params: readonly (string | number | null)[]) => db.query(sql).all(...params); const ledger = sqliteLedger({ execute: all, transaction: (statements) => db.transaction(() => statements.map(({ sql, params }) => all(sql, params))).immediate(), }); ``` ### Cloudflare D1 [#cloudflare-d1] D1 has no interactive transactions: `db.batch()` runs a list of statements atomically, which is exactly what `transaction` asks for. ```ts const ledger = sqliteLedger({ execute: async (sql, params) => (await env.DB.prepare(sql).bind(...params).all()).results, transaction: async (statements) => (await env.DB.batch(statements.map(({ sql, params }) => env.DB.prepare(sql).bind(...params)))).map((result) => result.results), }); ``` D1 returns every `INTEGER` as a JavaScript number, which is why money is read as `TEXT`. Full Worker: [Charge per call on Cloudflare Workers](/docs/guides/cloudflare-workers). ## Why batches [#why-batches] A transaction that reads, decides, and writes needs a connection held across `await`s. Synchronous drivers share one connection across concurrent requests, and D1 cannot hold one open at all. So every decision is expressed in SQL: * **`createCharge`** is one batch. Its first statement computes the status (`exists`, `missing`, `expired`, an unstorable amount, a currency mismatch, `busy`, `insufficient`, or `created`) in a single `CASE`; the insert, reservation, and history row are conditioned on the same expression. * **`transitionCharge`** is one batch. Every statement carries the same compare-and-set condition on the charge's id and both axes, and only the last one changes the charge, so the accounting update, history row, and charge update all match or none do. * **`replaceAuthorizationData`** is one `UPDATE`, called by core with the rail's `redact` output when a single-use charge becomes final. * **`openAuthorization`** is `INSERT … ON CONFLICT DO NOTHING RETURNING` plus a `SELECT`. **`claim`** is one `INSERT … ON CONFLICT DO UPDATE … WHERE expired RETURNING`. Because SQLite runs one write transaction at a time, concurrent `createCharge` calls cannot over-reserve an authorization: for a single-use authorization, one wins and the rest are `busy`. ## Tables [#tables] | Table | Holds | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------- | | `tollstile_authorizations` | What a payer authorized: rail, kind, `limit`, and the `reserved` / `consumed` totals of its charges. | | `tollstile_charges` | Each economic effect: amount, `payment` × `fulfillment` state, pending operation, settlement and refund references. | | `tollstile_charge_transitions` | Append-only history. Every transition adds a row in the same transaction. | | `tollstile_claims` | Single-use keys (nonces, replay windows) until `expires_at`. | * **Money** is integer micros in a 64-bit `INTEGER` with a currency code. Amounts outside `0 … 2^63 − 1` are refused with `INVALID_AMOUNT`, and another currency than the authorization's with `CURRENCY_MISMATCH`. `STRICT` tables refuse SQLite's silent overflow to `REAL`, so an overflowing total fails the transaction. * **Timestamps** are `INTEGER` milliseconds since the Unix epoch, UTC. * **JSON** is `TEXT`, checked with `json_valid`. ```sql -- Expired claims can be deleted at any time DELETE FROM tollstile_claims WHERE expires_at < (unixepoch() - 86400) * 1000; ``` ## Verification status [#verification-status] Tested with `node:sqlite` (SQLite 3.50.4) on Node 22: * The shared conformance suite, twice: with the synchronous adapter above, and through an adapter that behaves like D1 (every call resolves on a later turn, `INTEGER` columns returned as `bigint`). It covers every `createCharge` status, concurrent charges on one authorization, compare-and-set conflicts, accounting through every payment state, currency and amount refusals, `replaceAuthorizationData`, history, `pendingCharges`, `spendSince`, claim expiry, and amounts up to 2^63 − 1. * End-to-end flows through `createTollstile`, rollback of a whole batch, two connections sharing one WAL file, index usage, and overflow refusal. Not verified: * **D1, better-sqlite3, and bun:sqlite.** Their adapters above were written from documentation and not executed. To verify, run `test/ledger-conformance.ts` from the repository against each adapter; for D1, inside `@cloudflare/vitest-pool-workers` or `wrangler dev` with a local database. * **Multi-process contention** on one file, which relies on SQLite's file locking and `busy_timeout`. # KYAPay (/docs/rails/kyapay) ```bash npm install tollstile @tollstile/kyapay ``` ```ts import { createTollstile } from "tollstile"; import { tollstile } from "@tollstile/hono"; import { kyapay } from "@tollstile/kyapay"; import { postgresLedger } from "@tollstile/postgres"; const toll = createTollstile({ rails: [ kyapay({ environment: "sandbox", sellerId: process.env.SKYFIRE_SELLER_ID, serviceId: process.env.SKYFIRE_SERVICE_ID, apiKey: process.env.SKYFIRE_API_KEY, }), ], ledger: postgresLedger({ query, transaction }), secret: process.env.TOLLSTILE_SECRET, }); app.get("/report", tollstile(toll.price("$0.01")), (c) => c.json({ ok: true })); // Resolves charges whose outcome Skyfire left unknown. setInterval(() => void toll.reconcile(), 5 * 60_000); ``` A KYAPay payment token is a funded hold the buyer mints with Skyfire. The rail verifies the token on every request, runs your handler on a reservation, then charges the delivered amount against the token with Skyfire's seller API. Buyers send it in the `KYAPay-Token` header, or `_meta["kyapay/token"]` over MCP. ## Options [#options] | Option | Default | Meaning | | ------------------------ | ---------------------------------- | --------------------------------------------------------------------------------------------------------- | | `environment` | required | `"production"` or `"sandbox"`. Selects the issuer, the API, and the `env` claim tokens must carry. | | `sellerId` | required | Your seller agent id. Tokens must name it in `aud`. | | `serviceId` | required | Your seller service id. Tokens must name it in `tsi` (or the older `ssi`). | | `apiKey` | required | Seller agent API key, sent as `skyfire-api-key` to charge and list charges. Never logged or stored. | | `tokenTypes` | `["pay", "kya-pay"]` | Accepted token types. `["pay"]` keeps buyer identity claims out of your ledger. | | `issuers` | Skyfire's issuer for `environment` | Trusted issuer origins, checked before any key fetch. JWKS is read from `/.well-known/jwks.json`. | | `apiUrl` | Skyfire's API for `environment` | Seller API origin. | | `clockSkewSeconds` | `30` | Clock tolerance, 5–60 seconds. Also used when comparing Skyfire charge timestamps. | | `verifyRequestSignature` | none | RFC 9421 check for sender-constrained tokens (`cnf`). Without it, those tokens are refused. | | `fetch`, `clock` | globals | For tests. | ## Capabilities [#capabilities] | Capability | Value | Why | | -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `flows` | `authorization` | Skyfire's documented flow is verify, deliver, charge; a token's funds are committed when it is minted. | | `authorization` | `reusable` | A token is charged many times until exhausted. `limit` = `amt`, `expiresAt` = `exp`, proof id = `iss` + `jti`, payer = `sub`. | | `variableAmount` | `true` | A charge may be any amount up to the remaining balance, so `upTo()` prices work. | | `quotes` | `false` | Buyers mint tokens with Skyfire; nothing the server sends comes back inside the token. **Fixed prices only**: a dynamic-price route with this rail is refused at startup. | | `refund` · `partialRefund` | `false` | Skyfire documents no refund, void, or reversal. Not charging is the only release. | | `lookup` | `true` | `GET /api/v1/tokens/{jti}/charges`, with the accounting proof below. | `livemode` is `true` in both environments. ## Verification [#verification] In order, with no network call until the issuer is trusted: 1. Read `KYAPay-Token` (comma-separated or repeated). Members are classified by `typ`; `kya` tokens are ignored. No payment token is `absent`; more than one is `multiple_payment_tokens`. 2. `alg` must be `ES256`; `crit` is refused; `kid` is required; the token type must be accepted. 3. `iss` must be on the allow list (`untrusted_issuer`, nothing fetched). 4. ES256 signature against the issuer's JWK. JWKS is cached 60 minutes; an unknown `kid` refetches at most once a minute. 5. `aud` = `sellerId`, `env` = `environment`, `tsi`/`ssi` = `serviceId`, `sub` present, `jti` a UUID, `exp`/`iat`/`nbf` within `clockSkewSeconds`, lifetime at most 24 hours. 6. Payment claims: `cur` = `USD`, `amt` > 0 with at most 6 decimals. **Card-settled tokens are refused**, so card credentials never reach the ledger. 7. `cnf` present: `verifyRequestSignature` must pass. 8. `amt` must cover the route price. ## Settlement and lookup [#settlement-and-lookup] Skyfire's charge API accepts no idempotency key and returns no charge id, so a Tollstile charge can never be matched to a Skyfire charge directly. The rail reasons from the ledger's own accounting for the token: ```txt excess = Skyfire's listed total − ledger consumed (charges recorded as settled) others = ledger reserved − this charge's reservation (the most other in-flight charges could add) "charged" is possible ⇔ 0 ≤ excess − amount ≤ others, and a listed charge is not older than this charge (less clock skew) "absent" is possible ⇔ 0 ≤ excess ≤ others ``` | The charge list shows | `lookup` | `settle` before charging | | --------------------------------------------------- | --------------- | ---------------------------------- | | only "charged" possible | `settled` | returns `settled` without charging | | only "absent" possible | `none` | charges | | both possible (same-amount charges in flight) | stays `unknown` | charges | | less than the ledger recorded (list lagging) | stays `unknown` | charges | | neither possible (something else charged the token) | stays `unknown` | stays `unknown` | | HTTP `404` | stays `unknown` | charges | Charge responses: `200` with `amountCharged` equal to the requested amount is settled. A `4xx` with a documented Skyfire error code is rejected: the output is withheld and the client gets a fresh `402` with `reason: "settlement_rejected"`. Anything else — `5xx`, non-JSON, an unknown code, a different amount, a timeout — is `unknown`, and reconciliation resolves it. The settlement reference is `:`. Residual risks: * The proofs assume your ledger is the **only** party charging these tokens with your API key. * They assume the charge list shows every accepted charge by the time it is read. Run `reconcile()` with `olderThanMs` well above Skyfire's list delay (the default 15 minutes). * Two or more `unknown` charges with overlapping amounts on one token can stay unknown permanently. They are reported through `onEvent` on every reconcile and must be resolved by hand against the Skyfire dashboard. * Skyfire accepts charges for 24 hours after `exp`. A charge still unresolved after that is rejected by Skyfire. ## Stored data [#stored-data] The authorization's `data` is `{ token, tokenId }`. The compact JWT is stored because Skyfire charges only against the full signed token, and the charge happens after the handler, possibly in another process. It can be charged only by the seller in `aud`, with that seller's API key, which is never stored. `kya-pay` tokens carry buyer identity claims; set `tokenTypes: ["pay"]` to keep them out of the ledger. The rail does not implement `redact`: a reusable token must stay chargeable until it is exhausted or expires. ## 402 challenge [#402-challenge] KYAPay defines no challenge format. `accepts[].details` names the `KYAPay-Token` header, the accepted token types, the issuer, where to create tokens, and a message, and includes the A2A extension's `kyapay.payment.required` shape. MCP challenges use `{ style: "tollstile", … }` with the same fields. Receipts: the `kyapay-receipt` header, or `_meta["kyapay/receipt"]`, as `{ success, amount_charged, token_id }`. Skyfire recommends `403` for a missing token and `401` for an invalid one; Tollstile answers `402` for both, with the reason in the body. ## Verification status [#verification-status] **Tested only against fakes. Nothing has been run against Skyfire.** Tests use ES256 keys generated in the test, a fake JWKS endpoint, and a fake Skyfire API built from the documented request and response shapes. The settlement and lookup logic is marked experimental in the package until Skyfire confirms: 1. Whether charges are listed immediately after `POST /tokens/charge` returns, and the maximum delay if not. 2. Whether the charge list returns `404` or an empty list for a token with no charges. 3. Whether any `4xx` from the charge endpoint can accompany an applied charge. 4. Whether `chargedAt` is Skyfire's server time, and its precision. 5. That the listed `value` is exactly the submitted amount. 6. Which of `env`, `tsi` or `ssi`, and `sti.verified` production tokens carry. To verify in sandbox: create a seller agent and service, mint a `pay` token with a buyer agent, and call a route priced below the token amount. Check that each request produces exactly one charge, that cutting the network during a charge leaves it `unknown` and `reconcile()` resolves it without a second charge, and that a request after the token is exhausted is refused. # L402 (/docs/rails/l402) ```bash npm install tollstile @tollstile/l402 ``` ```ts import { createTollstile } from "tollstile"; import { tollstile } from "@tollstile/hono"; import { l402, lndRest } from "@tollstile/l402"; const toll = createTollstile({ rails: [ l402({ network: "signet", invoices: lndRest({ url: "https://127.0.0.1:8080", macaroon: invoiceMacaroonHex }), // Your exchange rate: USD micros → millisatoshis. Tollstile never fetches or hardcodes a BTC price. rate: (amount) => (amount.micros * msatPerUsd()) / 1_000_000n, secret: process.env.L402_SECRET, calls: 100, // one invoice buys 100 calls at the challenged price }), ], ledger, secret: process.env.TOLLSTILE_SECRET, }); app.get("/weather", tollstile(toll.price("$0.01")), (c) => c.json({ forecast: "clear" })); ``` ```bash curl -i localhost:3000/weather # HTTP/1.1 402 Payment Required # WWW-Authenticate: LSAT macaroon="AgE…", invoice="lntbs…" # WWW-Authenticate: L402 macaroon="AgE…", invoice="lntbs…" # pay the invoice, then: curl -i -H "Authorization: L402 AgE…:" localhost:3000/weather # HTTP/1.1 200 OK # l402-receipt: :chg_… # l402-remaining: $0.99 ``` Works with aperture-style clients such as `lnget`. Each call consumes part of what was prepaid; a call whose handler fails gives its part back. ## Options [#options] | Option | Default | Purpose | | ------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `network` | required | `mainnet`, `testnet`, `signet`, or `regtest`. Invoices for another network are refused. | | `invoices` | required | An `InvoiceProvider`: `{ createInvoice({ amountMsat, memo, expirySeconds, signal }), lookupInvoice(paymentHash, signal) }`. `lndRest()` is built in. | | `rate` | required | `(amount: Money) => bigint \| Promise`: millisatoshis for an amount in the price currency. Return whole satoshis if your payers' wallets need them. `0n` or less offers nothing for that price. | | `secret` | required | 32+ characters. Each macaroon's root key is `HMAC-SHA256(secret, identifier)`, so no root keys are stored. A list rotates: the first mints, all verify. Removing a secret invalidates credentials already paid for. | | `calls` | `1` | How many calls at the challenged price one credential pays for. The invoice is for `price × calls`. | | `credentialTtlMs` | 24 hours | How long a credential can be used after its challenge. | | `confirmSettled` | `false` | Also ask the node on every verification whether the invoice is settled. | | `invoiceTimeoutMs` | 10 seconds | Upper bound for creating an invoice while issuing a challenge. | | `clock` | system clock | For tests. | `lndRest({ url, macaroon, fetch })`: `macaroon` is hex (`xxd -p -c 1000 invoice.macaroon`); the invoice macaroon is enough. LND serves a self-signed certificate: pass a `fetch` that trusts it, or set `NODE_EXTRA_CA_CERTS`. **`confirmSettled`.** A correct preimage already proves payment: the node reveals it only when it settles. Confirming costs a round trip per request and turns node outages into `503`s for credentials that were already paid. It guards against preimages that became known without payment — a compromised node, or hold invoices settled out of band. ## Capabilities [#capabilities] | Capability | Value | Why | | -------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `flows` | `authorization` | The payment happened before the credential was presented. Each call reserves part of its value, runs, then consumes it; a failed handler releases it. | | `authorization` | `reusable` | A credential is used until its value or expiry runs out. | | `variableAmount` | `true` | Consumption can be any amount up to the reservation, so `upTo()` prices work. | | `quotes` | `true` | The quote token is a first-party caveat, `tollstile_quote=`, in the macaroon minted for that quote. | | `refund` · `partialRefund` | `false` | A settled Lightning payment cannot be pulled back. | | `lookup` | `true`, always `none` | See below. | `livemode` is `true`. ## Settlement and lookup [#settlement-and-lookup] **Settlement is consumption.** `settle` sends nothing to the node and returns the same reference (`:`) on every retry, so it is never ambiguous. The ledger moves the amount from reserved to consumed. **Lookup returns `none` for every charge.** The invoice being paid is a fact about the credential, not about one call. Reporting `settled` from it would let reconciliation consume value for a call whose service may not exist. With `none`, reconciliation re-runs the deterministic settle for charges whose handler completed and releases the rest. **Proof id is the payment hash.** aperture and `lnget` append `preimage=` to the macaroon, which changes its bytes. Keying on the payment hash makes both the same credential. ## Verification [#verification] 1. Read `Authorization: L402 :` (or `LSAT`). Over MCP, the same string in `_meta["l402/credential"]`. 2. Decode the V2 macaroon; the identifier uses aperture's v0 layout. 3. Verify the HMAC chain against every configured secret, in constant time. 4. Check `sha256(preimage) == payment_hash`. 5. Read caveats. The first three are the terms this rail minted (`tollstile_quote`, `tollstile_limit`, `tollstile_valid_until`). Caveats appended by a holder may only restrict: `preimage=` must match, a later `tollstile_valid_until` shortens that presentation, and anything else is refused. 6. Open the quote. If it opens, its price is charged. If it no longer opens (expired, or another resource), the call is charged the route's current fixed price; a dynamic-price route answers `quote_required`. 7. Optionally confirm the invoice with the node. Invalid reasons: `malformed_credential`, `conflicting_credentials`, `multiple_macaroons_unsupported`, `macaroon_invalid`, `preimage_mismatch`, `caveat_missing`, `caveat_malformed`, `caveat_conflict`, `caveat_unsupported`, `credential_expired`, `quote_required`, `currency_mismatch`, `invoice_not_settled`. Every invalid credential gets a `402` with a fresh challenge, which is what `lnget` expects. ## MCP [#mcp] L402 defines no MCP transport. The challenge is `{ style: "tollstile", rail: "l402", meta: "l402/credential", format: "L402 :", macaroon, invoice, paymentHash, value, calls, validUntil }`, rendered by the MCP adapter as an `isError` result with the denial body in `_meta["tollstile/payment-required"]`. Send the credential as `_meta["l402/credential"]`; the receipt is `_meta["l402/receipt"] = { reference, remaining }`. ## Stored data [#stored-data] The authorization's `data` is `{ paymentHash }`. Neither the macaroon, the preimage, nor any root key is stored: root keys are derived from `secret`, so nothing needs to be redacted and the rail does not implement `redact`. ## Things to know [#things-to-know] * **Every challenge creates an invoice on your node**, including for unauthenticated requests. Rate-limit unpaid requests in front of Tollstile. * **If invoice creation fails**, core leaves the L402 offer out of the `402` and emits an `error` event; other rails still offer. If no rail can offer, the answer is `503 payment_unavailable`. * The macaroon carries the quote token, so challenge headers are a few kilobytes when several rails are configured. * L402 uses the `Authorization` header. Routes that also authenticate callers with `Authorization` cannot use this rail on the same request. * **On dynamic-price routes, a credential works only while its quote opens** (`quoteTtlMs`, 5 minutes by default, and only on the quoted resource). Sell multi-call credentials (`calls > 1`) for fixed-price routes. * A credential's value is fixed in the price currency. It can be spent on any route priced in that currency. * `l402-receipt` and `l402-remaining` are Tollstile's headers; the L402 spec defines no receipt. ## Verification status [#verification-status] **Tested only against fakes and published vectors. Not verified against a running LND, a real Lightning payment, `lnget`, or aperture's client.** * Macaroon V2 encoding and HMAC chain: libmacaroons and go-macaroon vectors, byte for byte. * `preimage=` appended the way go-macaroon does it, and the challenge parsed with `lnget`'s regular expression. * LND REST shapes against a fake `fetch` built from LND's API definitions. * BOLT 11 amounts and networks from the human-readable part only; invoice signatures are not checked. To verify live on regtest: run two LND nodes with a channel (for example with Polar), point `lndRest()` at the merchant node with `calls: 3`, pay the challenged invoice from the other node, and call with `Authorization: L402 :`. Three calls must succeed and the fourth be challenged; a handler that returns `500` must leave `l402-remaining` unchanged; `lnget` requests must map to one authorization. # MPP (/docs/rails/mpp) ```bash npm install tollstile @tollstile/mpp ``` ```ts import { createTollstile } from "tollstile"; import { mppStripe, mppTempo } from "@tollstile/mpp"; const toll = createTollstile({ rails: [ mppStripe({ realm: "api.example.com", secret: process.env.MPP_SECRET, // binds challenge ids; 32+ characters, a list rotates secretKey: process.env.STRIPE_SECRET_KEY, networkId: "profile_1MqDcVKA5fEO2tZvKQm9g8Yj", }), mppTempo({ realm: "api.example.com", secret: process.env.MPP_SECRET, rpcUrl: "https://rpc.moderato.tempo.xyz", chainId: 42431, recipient: "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00", token: { address: "0x20c0000000000000000000000000000000000000", code: "pathUSD" }, denomination: "USD", }), ], ledger, secret: process.env.TOLLSTILE_SECRET, }); ``` Tutorial: [Accept MPP payments](/docs/guides/accept-mpp-payments). | Rail | Name | MPP method / intent | Flow | Status | | ------------------- | ------------------- | ----------------------------------------------------------- | --------------- | --------------------------------- | | `mppStripe()` | `mpp-stripe` | `stripe` / `charge` (Shared Payment Tokens) | `upfront` | Implemented, tested against fakes | | `mppTempo()` | `mpp-tempo` | `tempo` / `charge` (TIP-20 transfer, pull and push) | `authorization` | Implemented, tested against fakes | | `mppTempoSession()` | `mpp-tempo-session` | `tempo` / `session` v2 (payment channels, `voucher` action) | `upfront` | **Experimental** | ## Wire format [#wire-format] Shared by every MPP rail. * **Challenge:** one `WWW-Authenticate: Payment id, realm, method, intent, request, expires, opaque` per rail. `request` and `opaque` are base64url of RFC 8785 (JCS) JSON. `expires` is the quote's expiry. The `402` body's `accepts[].details` carries the same challenge as an object. * **Binding:** `id` is an HMAC-SHA256 over the challenge with `secret`, matching mppx's published vectors. The first secret signs; every secret verifies. * **Quote:** Tollstile's signed quote travels in `opaque`, bound by the HMAC, so the quoted price is what is charged. * **Credential:** `Authorization: Payment ` with the echoed `challenge` and the method's `payload`; over MCP, `_meta["org.paymentauth/credential"]`. Verification checks the id, realm, expiry, quote, that the echoed `request` is byte-identical to what this server issues, then the method's proof. Credentials for another method or intent are ignored, so several MPP rails share one route. * **Receipt:** `Payment-Receipt` plus `Cache-Control: private` over HTTP; `_meta["org.paymentauth/receipt"]` over MCP. * **MCP challenge:** JSON-RPC error `-32042` with `data.challenges` when the client declared `capabilities.experimental.payment`. Verification failures also use `-32042`, with `data.failure.reason`. * **Proof id:** the challenge id, single-use through the ledger; for sessions, the channel id. ## `mppStripe(options)` [#mppstripeoptions] | Option | Default | Purpose | | --------------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | `realm`, `secret` | required | Challenge realm and HMAC secret(s) | | `secretKey` | required | Stripe API key, sent only as `Authorization: Bearer` | | `networkId` | required | Stripe Business Network Profile id (`methodDetails.networkId`) | | `paymentMethodTypes` | `["card"]` | `methodDetails.paymentMethodTypes` | | `apiVersion` | `"2026-07-29.preview"` | `Stripe-Version`. Shared Payment Tokens require a preview version | | `sptParameter` | `"shared_payment_granted_token"` | Or `"payment_method_data[shared_payment_granted_token]"`, if your account needs the form Stripe's SPT guide shows | | `searchLagMs` | 10 minutes | How long a Stripe Search miss is not trusted after an ambiguous settlement | | `apiBase`, `fetch`, `clock` | Stripe, global, system | Injection points | | Capability | Value | Why | | -------------------------- | --------------- | ---------------------------------------------------------------------------- | | `flows` | `upfront` | Confirming a PaymentIntent captures immediately; there is no hold to release | | `authorization` | `single` | One token, one payment | | `refund` · `partialRefund` | `true` · `true` | Stripe Refunds API | | `variableAmount` | `false` | The token is granted for the challenged amount | | `quotes` | `true` | Carried in `opaque` | | `lookup` | `true` | By PaymentIntent id, or Stripe Search | * **Offers:** none for currencies without a known minor unit, amounts finer than the minor unit (sub-cent USD), or amounts below Stripe's minimum (USD $0.50, GBP £0.30, …). * **Flow:** Stripe settles before your handler. A declined payment answers `402` (`payment_rejected`) and an unanswered one `503`, both before the handler runs. A handler that fails is refunded. * **Idempotency key:** `tollstile_mpp_`, one per challenge. A retry of a released challenge, even with a new token, replays the first PaymentIntent instead of charging twice. * **Lookup:** by PaymentIntent id when known, otherwise Stripe Search on `metadata['challenge_id']`, re-checking every hit. `processing` and `requires_capture` stay `unknown`. Refunds are found by `metadata.tollstile_charge`. * **Search lag risk:** a Search miss younger than `searchLagMs` stays `unknown`. If Search lags longer than that, reconciliation releases a charge whose PaymentIntent exists: the payer is charged and the ledger says released. Keep `searchLagMs` generous and reconcile Stripe payouts against the ledger. **Stored data.** `{ challengeId, amount, currency }`. The Shared Payment Token is a bearer token and never reaches the ledger: it stays in process memory between verification and settlement in the same request. Reconciliation never re-settles an upfront charge; it looks it up. ## `mppTempo(options)` [#mpptempooptions] | Option | Default | Purpose | | ------------------- | -------------- | --------------------------------------------------------------------------------------------- | | `realm`, `secret` | required | Challenge realm and HMAC secret(s) | | `rpcUrl`, `chainId` | required | Tempo JSON-RPC (`4217` mainnet, `42431` Moderato) | | `recipient` | required | Payee address | | `token` | required | TIP-20 `{ address, code }` (6 decimals) | | `denomination` | required | Price currency the token is worth at par, e.g. `"USD"`. Other currencies get no offer | | `modes` | `["pull"]` | Add `"push"` to accept transfers the payer already broadcast (below) | | `splits` | none | `(amount) => [{ recipient, amount, memo? }]` in base units; the sum must stay below the total | | `validityMarginMs` | 60 seconds | Block-timestamp skew allowed past a transaction's `validBefore` | | `fetch`, `clock` | global, system | Injection points | | Capability | Value | Why | | -------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------- | | `flows` | `authorization` | A pull transaction can be broadcast any time before `validBefore`, so it is broadcast only after the handler succeeds | | `authorization` | `single` | One transfer | | `refund` · `partialRefund` | `false` | Refunding would need a merchant signing key | | `variableAmount` | `false` | The signed amount is fixed | | `quotes` | `true` | Carried in `opaque` | | `lookup` | `true` | `eth_getTransactionReceipt` by transaction hash | * **Binding on-chain:** every challenge carries `methodDetails.memo`, derived from the realm and the quote, and the primary transfer must be `transferWithMemo` with it. One on-chain payment cannot satisfy two challenges. * **Pull verification (offline):** strict decoding of the `0x76` transaction, sender recovery, chain id, `validBefore` in the future and not after the challenge expiry, and calls that are exactly the required transfers. Fee sponsorship, key authorizations, authorization lists, non-secp256k1 signatures, and extra calls are refused. * **Settlement:** `eth_sendRawTransactionSync` after the handler. Rebroadcasting the same bytes cannot transfer twice. A lost answer stays `unknown` until the transaction can no longer be included (`validBefore` plus the margin); only then is it rejected. * **Residual risk:** between verification and broadcast, the payer can spend the nonce or the balance. The handler has then run unpaid; the charge ends `failed/completed` and `onEvent` reports `SETTLEMENT_REJECTED`. * **Push mode:** the payer broadcasts and sends the hash; the receipt's transfer logs are checked at verification, and the payment has already moved when the handler runs. Core records it as `upfront`, settled before the handler. The rail cannot refund, so a failed handler leaves the charge `settled/failed` with a `REFUND_REJECTED` event, and reconciliation skips it. Enable push only if you will refund those payments yourself. **Stored data and redaction.** The signed pull transaction is stored in the authorization's data so settlement survives a crash. `redact` drops it once a charge is final; the hash and `validBefore` stay for lookup. A released charge keeps it, so the same credential can be retried. ## `mppTempoSession(options)` — experimental [#mpptemposessionoptions--experimental] Options: `realm`, `secret`, `rpcUrl`, `chainId`, `recipient` (the channel payee), `token`, `denomination`, `escrow` (defaults to the TIP-20 channel escrow precompile), `operator` (defaults to none), `fetch`, `clock`. | Capability | Value | Why | | ---------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------- | | `flows` | `upfront` | Voucher coverage can only be checked at settlement, so an uncovered call is refused before it runs | | `authorization` | `reusable` | The authorization is the channel; its limit is the deposit minus what was settled on-chain when first seen | | `refund` | `true` | Nothing is captured per charge; a refund removes the charge from consumption | | `partialRefund` · `variableAmount` | `false` | Not implemented | | `lookup` | `true` | Settle and refund have no external effect, so lookup is exact | * **Verification** accepts `voucher` credentials: the channel descriptor, the EIP-712 voucher signature, and live channel state (exists, no close requested, voucher within the deposit). * **Settlement** accepts a voucher only if it covers everything consumed and reserved on the channel, including in-flight charges. * **`settled` does not mean funds moved.** It means you hold a payer-signed voucher. Funds move when you close the channel with the calldata from `tempoSessionClose({ authorization, charges, settledOnChain? })`, submitted from the payee account with your own wallet. * **Guarantee gap:** a payer can request a close and withdraw after the escrow's grace period (15 minutes in the reference contract). Anything not captured by then is lost, even though the ledger says `settled`. Watch for `CloseRequested` and close promptly. * **Not supported:** `open`, `topUp`, and `close` credentials (the payer opens and funds the channel on-chain first), session protocol v1, top-ups raising the ledger limit, and streaming metering. * **Why experimental:** vouchers pass from verification to settlement in process memory, which rules out the `authorization` flow and variable prices until core can persist per-charge proofs. ## Verification status [#verification-status] **Tested only against in-process fakes and published vectors. Not verified against Stripe or a Tempo node.** * Challenge ids: mppx 0.9.3's HMAC test vectors. JCS: RFC 8785 examples. * Stripe: an in-memory Stripe with idempotency replay and conflicts, Search visibility lag, refunds, declines, 5xx responses, and dropped connections. * Tempo: transactions built and signed in the tests, and a fake JSON-RPC node. No bytes from a real Tempo client were used. To verify live: pay a $0.50+ route with a Stripe test-mode Shared Payment Token and confirm a `succeeded` PaymentIntent with `metadata.challenge_id`, force a handler failure and confirm the refund, and run `npx mppx@latest validate `. On Moderato, pay a Tempo challenge with the `mppx` client in pull mode and find the transaction hash from the receipt on the explorer. For sessions, open a v2 channel with the `mppx` session client, send vouchers, and submit `tempoSessionClose()` calldata. # Test rail (/docs/rails/test) ```ts import { createTollstile, memoryLedger, testRail } from "tollstile"; export const toll = createTollstile({ rails: [testRail()], ledger: memoryLedger() }); ``` Send `Payment: test` to pay a fixed price, or pay against the quote from the `402`: ```txt Payment: test quote= proof=p1 payer=agent_1 amount=$0.01 limit=$1.00 ``` | Parameter | Effect | | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `quote` | The quote token from the `402`. Required on dynamic routes. | | `proof` | Stable proof id. Without it, every request is a fresh payment. | | `payer` | Payer id, used by `limit()` and `payers()`. | | `amount` | Must equal the price, otherwise the proof is invalid. | | `limit` | Capacity of a reusable authorization. | | `signature` | Stands in for payer evidence a real rail keeps until the charge is final. It is stored in the authorization's data and dropped by the rail's `redact` once the charge is settled. | | Option | Default | Effect | | --------------- | ---------- | ------------------------------------------------------------------------------------------------------ | | `authorization` | `"single"` | `"reusable"` behaves like an L402 credential or a KYAPay token | | `refund` | `true` | `false` behaves like a rail that cannot refund, such as x402: only the `authorization` flow is offered | Over MCP, send the same string in `_meta["tollstile/test-payment"]`; the receipt is `_meta["tollstile/test-receipt"]`. See [Test payment failures](/docs/guides/test-payment-failures) for `rail.simulate()` and `rail.effects`. `createTollstile()` refuses a test rail configured together with a live rail. # x402 (/docs/rails/x402) ```bash npm install tollstile @tollstile/x402 ``` ```ts import { createTollstile, upTo } from "tollstile"; import { tollstile } from "@tollstile/hono"; import { x402 } from "@tollstile/x402"; const toll = createTollstile({ rails: [ x402({ network: "eip155:84532", // Base Sepolia payTo: "0xYourAddress", denomination: "USD", // 1 USDC = 1 USD, stated explicitly rpcUrl: "https://sepolia.base.org", upto: { facilitatorAddress: "0xd407e409E34E0b9afb99EcCeb609bDbcD5e7f1bf" }, // from GET /supported }), ], ledger, secret: process.env.TOLLSTILE_SECRET, // 32+ random characters }); app.get("/weather", tollstile(toll.price("$0.01")), (c) => c.json({ sunny: true })); app.post("/generate", tollstile(toll.price(upTo("$0.10"))), async (c) => { await c.get("payment").fulfill({ amount: "$0.03" }); // settles 0.03 USDC of the 0.10 authorized return c.json({ text: "…" }); }); setInterval(() => void toll.reconcile(), 60_000); ``` Tutorials: [Monetize an API with x402](/docs/guides/monetize-an-api-with-x402) · [Express](/docs/guides/x402-with-express) · [MCP tools](/docs/guides/charge-for-mcp-tools). | | | | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | Rail name | `x402` | | Schemes | `exact` (EIP-3009 `transferWithAuthorization`) for fixed prices; `upto` (Permit2) for `upTo()` prices | | HTTP | `PAYMENT-REQUIRED` / `PAYMENT-SIGNATURE` / `PAYMENT-RESPONSE`, base64 JSON. Only `x402Version: 2` | | MCP | Proof in `_meta["x402/payment"]`, receipt in `_meta["x402/payment-response"]`, payment required as an `isError` tool result with `structuredContent` | | Reconciliation | On-chain, through your JSON-RPC endpoint | ## Options [#options] | Option | Default | Description | | ------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `network` | required | CAIP-2 EVM network. Built-in assets: `eip155:8453` (Base, USDC) and `eip155:84532` (Base Sepolia, USDC). | | `payTo` | required | Your receiving address. Every payment is checked against it. | | `denomination` | — | Conversion at par, e.g. `"USD"` for USDC. Set exactly one of `denomination` and `rate`; the built-in USDC only accepts `"USD"`. | | `rate` | — | `(price: Money) => Promise`: atomic asset units for a price, for assets not at par. The quote fixes the result for the payer. | | `asset` | built-in USDC | `{ code, address, decimals, name, version }` with the token's EIP-712 domain. Required on other networks; decimals 6–18. | | `facilitator` | x402.org on Base Sepolia only | `{ url, headers? }`. `headers: () => Promise>` runs per request, e.g. for a CDP JWT. Required on mainnet and every other network; the testnet facilitator is never used silently. | | `rpcUrl` | required | JSON-RPC endpoint for `network`, used only by reconciliation. Must support the `finalized` block tag and `eth_getLogs`. | | `upto` | disabled | `{ facilitatorAddress }` enables `upTo()` prices. Use the address your facilitator lists for `upto` in `GET /supported`. | | `maxTimeoutSeconds` | `60` | How long the payer's signature is valid. The handler and settlement must both finish inside it, or settlement is rejected after the service was delivered. | | `fetch` | global `fetch` | For tests and custom transports. | ## Capabilities [#capabilities] | Capability | Value | Why | | -------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------- | | `flows` | `authorization` | Verify, run the handler, then settle. `exact` cannot be refunded or voided, so settling first would charge for work that failed. | | `authorization` | `single` | One signed authorization pays for one request. After a released charge the same payment can be retried. | | `variableAmount` | `true` with `upto` | Permit2 `upto` authorizes a maximum and settles the fulfilled amount. Without `upto`, `upTo()` routes are refused at startup. | | `quotes` | `true` | The quote token travels in `accepts[].extra.tollstileQuote`, which V2 clients echo in `accepted`. | | `refund` · `partialRefund` | `false` | Neither scheme has a refund. | | `lookup` | `true` | On-chain, below. | `livemode` is `true`, including on testnets, so the rail cannot run next to the test rail. ## Flow [#flow] 1. **Challenge.** The `402` carries a `PAYMENT-REQUIRED` header with the requirements for this price, including `extra.tollstileQuote`. 2. **Verify.** The proof must be `x402Version: 2`. If it carries a quote, requirements are derived from the quote's offer; otherwise from the route's fixed price. `accepted` must equal those requirements, and the signed authorization must name `payTo`, the exact amount (or the `upto` maximum), the asset, the upto proxy as spender, and the configured facilitator. The facilitator's `/verify` is called with **this server's** requirements, never the client's. 3. **Run.** The handler runs on a reservation. 4. **Settle.** `/settle` is called with the stored payload. For `upto`, the amount is the fulfilled amount at the quoted ratio, rounded down. The proof id is `network:asset:payer:nonce`, so a replayed payment maps to the same authorization. | Facilitator answer | Result | | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | `/verify` says `isValid: false` (as HTTP 200 or a non-2xx JSON body) | `402` with the facilitator's reason, sanitized to `[a-z0-9_]` | | `/verify` unreachable, timed out, non-JSON, or `unexpected_verify_error` | `503`; the handler does not run | | `/settle` says `success: false` | Charge `failed`; the output is withheld and the client gets a fresh `402` with `reason: "settlement_rejected"` | | `/settle` answers `settlement_pending` or `unexpected_settle_error`, times out, or is unreachable | Charge `unknown`; the output is served without a receipt and reconciliation asks the chain | Tollstile never calls `/settle` twice on a hunch: facilitators have no status endpoint, and `/settle` is not idempotent. ## Lookup and reconciliation [#lookup-and-reconciliation] All reads happen at the `finalized` block. | Scheme | Settled when | Not settled when | | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | | `exact` | `authorizationState(payer, nonce)` is used, and the token's `AuthorizationUsed(payer, nonce)` log sits in a successful transaction with `Transfer(payer, payTo, value)` | An `AuthorizationCanceled` log | | `upto` | The Permit2 nonce bit is set, and a `Transfer(payer, payTo)` log's transaction called the upto proxy with this nonce, owner, and token; the amount comes from the log | An `UnorderedNonceInvalidation` covering the nonce | * **Unused nonce:** `none` only once the finalized block is past the signature's deadline, when no later block can include it. Before that the charge stays `unknown` until the next run. * **Used nonce without recognizable evidence** (for example, a facilitator settling through a batching contract): the charge stays `unknown`, with an error to investigate. Tollstile does not guess. * Logs are searched from the charge's creation time (minus 10 minutes of clock-skew margin) to the signature's deadline. ## Stored data and redaction [#stored-data-and-redaction] The authorization's `data` holds the payer's signed payload, because settlement may run in another process after a crash. It never appears in errors, events, or receipts. The rail implements `redact`. Once a charge is final, core replaces `paymentPayload` and `paymentRequirements` with `null`, keeping the scheme, network, asset, `payTo`, payer, nonce, deadline, and amounts that lookup needs. * While a charge is `unknown`, the payload stays, so reconciliation can still settle it. * A `released` charge is not redacted, so the same payment can be retried. ## Verification status [#verification-status] **Tested against fakes and the reference library. Not verified against a real facilitator or chain.** * Full flows through `createTollstile` with a fake facilitator and a fake JSON-RPC node sharing one simulated chain: `exact` and `upto`, dynamic prices, tampered requirements and signatures, expired quotes, facilitator outages (`503`), replay and concurrent replay, retry after release, rejected settlement, `settlement_pending` reconciled from chain evidence without a second `/settle`, payer cancellation, RPC outages, crash recovery, MCP challenge and receipt, and redaction. * Headers round-trip through `@x402/core` 2.25.0, and the reference `x402ResourceServer.findMatchingRequirements` accepts what the rail advertises. Not yet checked live: signature acceptance by x402.org, real facilitator error bodies, your RPC provider's `finalized` behavior and log range limits, and `upto` settlement through `settleWithPermit`. The [tutorial](/docs/guides/monetize-an-api-with-x402#verify-with-a-real-client) walks through a Base Sepolia run; the package README lists replay, failure, and reconciliation checks.