# 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. ## Check the current contract [#check-the-current-contract] Use [Core design](/docs/design), [Errors](/docs/concepts/errors), [Idempotency](/docs/concepts/idempotency), and the [conformance kit](/docs/conformance). In the repository, compare SPEC.md with `packages/tollstile/src/core/types.ts` and the implementation before changing behavior. Inspect `gate.plan` when composing rails; incompatible rails are excluded per route. Include a retry key in paying clients and document how stored results are retrieved. # Conformance test kit (/docs/conformance) `railConformance()` from `tollstile/testing` returns test cases for a rail and its fake provider. Writing a rail? Start with [Build a rail](/docs/rails/build-a-rail). It is part of the `tollstile` package, has no test-runner dependency, and does not contact a real provider on its own. ## Run the cases [#run-the-cases] The rail's test suite supplies `createHarness`. Register every returned case with your runner and report its skip reason: ```ts import { describe, it } from "vitest"; import { railConformance } from "tollstile/testing"; import { createHarness } from "./rail-harness"; describe("rail contract", () => { for (const test of railConformance(createHarness)) { if (test.skip !== undefined) { it.skip(`${test.name}: ${test.skip}`, () => test.run()); } else { it(test.name, () => test.run()); } } }); ``` `./rail-harness` is your test implementation, not a Tollstile export. See the repository's [test rail harness](https://github.com/tollstile/tollstile/blob/main/packages/tollstile/test/rail-conformance.test.ts) and [x402 harness](https://github.com/tollstile/tollstile/blob/main/packages/x402/test/rail-conformance.test.ts). ## RailHarness [#railharness] `createHarness: () => RailHarness` is synchronous. The kit creates a probe to inspect capabilities and hooks, then a fresh harness per case. Give each harness isolated provider state. | Field or method | Contract | | ----------------------------- | ----------------------------------------------------------------------------------------------- | | `rail` | The rail under test | | `clock` | Shared by rail and fake provider; `now(): Date` and `advance(ms): void` | | `price?` | Fixed price supported by the fake, default `"$1"` | | `pay({ denial, offer, url })` | Returns `Promise` carrying a valid payment; every invocation must create a fresh proof | | `settlements()` | Number of economic effects performed, synchronously or as a promise | | `loseNextSettleResponse?()` | Next settlement performs its effect, then throws `PROVIDER_TIMEOUT` | | `failNextSettle?()` | Next settlement performs no effect, then throws `PROVIDER_TIMEOUT` or `PROVIDER_UNAVAILABLE` | | `tamper?(request)` | Returns a copy with a proof the rail must reject | | `reconcileAfterMs?` | Time to advance before lookup/reconciliation; default one hour | The kit runs a `GET /conformance` gate with a memory ledger. `ConformanceCase` contains `name`, optional `skip`, and asynchronous `run()`. Failed assertions reject the case. ## What is checked [#what-is-checked] | Case | What it verifies | Conditional coverage | | ------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------- | | Usable contract | Name, lookup, flow/refund declarations; proof absent without payment | Always | | Challenge → pay → receipt | Integer offer amount, settlement and completed fulfillment, one provider effect, receipt | Always | | Single-use replay | Replay denied without another effect | Single-use rails only | | Tampered proof | Denial and no settlement | Requires `tamper` | | Failed handler | Release, upfront refund, or recorded unrefundable paid-at-verification outcome | According to flow/capabilities | | Repeated settlement | No second effect; recorded reference or rejection | Always | | Lost settlement response | Reconciliation resolves ledger against provider evidence | Requires `loseNextSettleResponse` | | Failure before effect | No false settled record after reconciliation | Requires `failNextSettle` | | Redaction | Stored redacted data, idempotent redaction, lookup still works | Single-use rail implementing `redact` | A skipped case is unverified coverage, not a successful test. The kit does not replace rail-specific tests for every malformed field, amount/asset/recipient mismatch, provider outage, refund ambiguity, concurrency, or protocol vector. It also does not prove live provider finality or availability. ## Other testing exports [#other-testing-exports] | Export | Use | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `fakeClock(start?)` / `FakeClock` | Controllable clock; default starts at `2026-01-01T00:00:00.000Z` | | `httpContext(request, options?)` | Context from a Web Request; options: `resource`, `principal`, `requestId`. Reads the HTTP idempotency header. | | `mcpContext(tool, meta, options?)` | Context with no HTTP carrier; options: `arguments`, `principal`, `clientCapabilities`, `requestId`. Reads the MCP idempotency key. | | `RailHarness`, `ConformanceCase` | Harness and case types | Use `testRail()` from `tollstile` for [failure simulation](/docs/guides/test-payment-failures). Its effect counters let tests assert no duplicate settlement or refund. ## Ledger and adapter conformance [#ledger-and-adapter-conformance] The shared ledger suites are repository test helpers, not exports of `tollstile/testing`: [Postgres suite](https://github.com/tollstile/tollstile/blob/main/packages/postgres/test/ledger-conformance.ts) and [SQLite suite](https://github.com/tollstile/tollstile/blob/main/packages/sqlite/test/ledger-conformance.ts). They exercise reservations, state transitions, accounting, claims, and persistence. Driver examples still need validation on the deployment database. Adapters are tested through their public behavior: challenge/payment round trip, failure release, rejected settlement withholding output, unknown settlement serving output without a receipt, and retry-key forwarding. SPEC.md defines the obligations; it does not imply that every planned suite is a public API. There is no exported `runRailConformance()` or `runPolicyConformance()` in the current package. Before publishing a rail, run its full test suite and complete the provider-specific checks listed on its documentation page. See [Roadmap](/docs/roadmap). # Core design (/docs/design) Tollstile separates **what was offered** (quote), **what the payer authorized** (authorization), **each economic effect** (charge), and **whether the service exists** (fulfillment). Rails implement payment protocols; policies decide who pays; adapters connect the runtime to a framework. This page describes the current implementation. [SPEC.md](https://github.com/tollstile/tollstile/blob/main/SPEC.md) defines the component contract; [DESIGN.md](https://github.com/tollstile/tollstile/blob/main/DESIGN.md) explains the model. The [TypeScript contracts](https://github.com/tollstile/tollstile/blob/main/packages/tollstile/src/core/types.ts) define the actual fields. Planned concepts such as `escrow` are not executable flows. ## Request pipeline [#request-pipeline] | Stage | Responsibility | Result | | ------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------ | | Request | Adapter normalizes HTTP or MCP input, principal, resource, request ID, and idempotency key | `Context` | | Policy | Core tries access policies in order | Grant, balance reservation, rail payment, or denial | | Price | Merchant configuration supplies a fixed amount, maximum, or computed price | `Money` and variable flag | | Negotiation | Planned rails offer terms; core signs a quote; rails render challenges | `402` payment challenge | | Authorization | Rail verifies the proof and returns canonical payer identity and stable proof ID | Stored authorization | | Execution | Requirements pass; core atomically reserves capacity on a charge | Handler admitted | | Metering | Handler marks fulfillment and, for `upTo()`, actual usage | Final amount and optional result reference | | Settlement | Core records intent, calls the rail, and records the known result or ambiguity | Charge state | | Receipt | Adapter acts on `Completion` | Output with receipt, output without receipt, or denial | Issuing a price or quote does not write payment records. A balance reservation or verified rail proof creates records; requirement nonce claims are separate writes. Core uses Web-standard requests and has no framework or protocol imports. ## Money, offers, and quotes [#money-offers-and-quotes] Money is `{ currency, micros: bigint }`: six decimal places, integer arithmetic. `$0.04` is `40000n` USD micros. A price currency is distinct from a settlement asset. Each offer records an asset, network, integer asset amount, flow, and explicit `par` or merchant-supplied `rate` basis. Quotes are signed with HMAC-SHA256, never stored, and expire after `quoteTtlMs` (five minutes by default). Their fields include resource, request commitment, price, variable flag, offers, nonce, and issuance/expiry dates. Rails with quote support carry the token inside their protocol and return the opened quote during verification. A single-use proof pays its quoted price and must match the selected commitment. Computed prices default to method/resource plus path/query and body bytes, or MCP tool and canonical arguments. Static prices default to method/resource only. A custom commitment covers the fields the merchant selects. Reusable authorizations pay the current request's price against remaining capacity. See [Quotes](/docs/concepts/quotes). ## Execution plans [#execution-plans] `toll.price()` compiles an `ExecutionPlan`. `gate.plan` exposes it as data; `toll.explain(gate)` formats it for people. | Plan field | Meaning | | -------------------------------- | -------------------------------------------------------------------------------------------------------- | | `route`, `pricing`, `commitment` | Route name; fixed, up-to, or computed pricing; route, request, or custom quote binding | | `access`, `requirements` | Names in configured order | | `rails` | Per rail: flow, settlement timing, authorization kind, fixed/up-to amounts, and handler-failure behavior | | `excluded` | Rail name, missing capability (`needs`), and explanation (`reason`) | A rail without lookup, or one declaring upfront without refunds, is invalid. A valid rail incompatible with this route is excluded. If no rail remains, defining the route throws `CAPABILITY_MISSING`. Core only verifies and offers planned rails. Computed `upTo()` results filter further to plans supporting variable settlement; if none remain, that request throws `CAPABILITY_MISSING`. The default prefers authorization; static `upTo()` requires authorization and variable amounts. Computed prices require quote support. `escrow` throws `CONFIG_INVALID`. See [Rails](/docs/concepts/rails#execution-plan). ## Authorizations and charges [#authorizations-and-charges] An authorization is identified by a hash of rail and proof ID. It records payer, kind, limit (nullable), consumed/reserved amounts, quote ID, expiry, and rail data. * `single`: at most one charge that has not been released. Reusing a released proof is possible if verification still accepts it. * `reusable`: multiple charges within capacity and expiry. Without a key, another admitted request creates another charge. A charge has independent payment and fulfillment states: ```txt payment: reserved → settling → settled → refund_pending → refunded ↓ ↓ refund ambiguity ↘ unknown released failed / unknown fulfillment: pending → running → completed ↓ failed ``` The [transition tables](https://github.com/tollstile/tollstile/blob/main/packages/tollstile/src/core/states.ts) define legal moves, including recovery out of `unknown`. Updates compare both axes before changing them. Reservation accounting and the transition history change atomically with the charge. `pending: "settle" | "refund"` distinguishes ambiguous operations. Creating a charge reserves capacity. Settlement commits it; release or refund removes its accounting contribution. A charge in a different currency or outside the ledger's signed 64-bit amount range is refused. See [Ledger](/docs/concepts/ledger). ## Flows and fulfillment [#flows-and-fulfillment] | Flow | Order | Failure before explicit fulfillment | | --------------- | ------------------------------ | ----------------------------------- | | `authorization` | Reserve → run → meter → settle | Release | | `upfront` | Reserve → settle → run | Refund | Payments already moved at verification are recorded as upfront regardless of the declared plan. If such a rail cannot refund, failed fulfillment remains `settled/failed` and is reported for merchant action. `payment.fulfill()` marks the service as existing. A later handler failure does not undo it. On variable prices, `fulfill({ amount })` must supply an amount between zero and the maximum. Zero releases; success without fulfillment also releases and emits `FULFILLMENT_MISSING`. Fixed prices cannot be reduced through `fulfill`. `fulfill({ resultRef })` records a non-secret reference of 1–1024 characters. Tollstile does not store the result itself or guarantee response delivery. ## Completion and error boundaries [#completion-and-error-boundaries] Adapters complete each admitted call once and act on its result: | Completion | Adapter behavior | | ---------- | ------------------------------------------------ | | `settled` | Attach the rail receipt and serve the output | | `rejected` | Withhold output and return the settlement denial | | `unknown` | Serve output without a receipt; reconcile later | | `none` | Serve the handler's own result | For a post-handler rejection, a fresh challenge is created only when the request body remains available. Otherwise the denial carries `settlement_rejected` without new offers. Withholding on an unknown settlement could leave a payer charged for output never sent. If recording completion fails, core emits an error and rethrows instead of presenting the output as paid. Expected denials use stable `error.code`, `retryable`, `action`, `message`, and optional rail/requirement detail. Provider outages during verification deny access. Provider timeout/unavailability during settlement or refund records ambiguity; unrelated exceptions propagate. See [Errors](/docs/concepts/errors) and each adapter's transport behavior. ## Idempotency [#idempotency] | Identifier | Derived from | | ----------------------- | ---------------------------------- | | Request ID | New identifier per inbound request | | Authorization ID | Rail and stable proof ID | | Charge ID without a key | Authorization and request ID | | Charge ID with a key | Payer, key, and attempt number | | Provider operation key | Charge ID and operation | HTTP uses `Idempotency-Key`; MCP uses `_meta["tollstile/idempotency-key"]`. Client keys override protocol-supplied keys. Keys deduplicate within the verified payer identity, which may be per challenge on some rails. Keyed charges persist a request hash. Route-only quote bindings are strengthened to full request matching for idempotency; custom commitments remain custom. Retries return recorded state: in progress, unknown, already paid (with result reference), or rejected. Released/refunded attempts can proceed to a new charge; a single-use authorization may still require a fresh proof. Subscriber grants create no charge. See [Idempotency](/docs/concepts/idempotency) for all limits. ## Policies and requirements [#policies-and-requirements] Policies return `skip`, `pay`, `grant`, or `reserve`. Insufficient balance after a reserve decision releases the attempt and falls through. Grant decisions create no payment record. Reserve decisions use a reusable authorization named `policy:` and a `Balance` implementing idempotent reserve, commit, release, and status operations. Requirements run after payer identity is known and before reserving value. They receive price, quote (possibly null), ledger reader, claims, clock, and abort signal. A temporary inability to verify evidence returns `503 requirement_unavailable`, not a permanent refusal. Nonce claims can prevent reuse even when a payment retry has the same key; renew identity evidence as needed. ## Rail contract and evidence [#rail-contract-and-evidence] Rails implement offer, challenge, verify, settle, refund, release, lookup, and receipt. Verification returns absent, invalid, or valid; an invalid proof may include a stable proof ID so core can recognize an already-used payment from the ledger. Provider calls receive a stable operation key and abort signal. A rail may retain settlement evidence in authorization data for crash recovery. Optional `redact` drops single-use evidence after final settlement, failure, or refund, retaining what lookup/refund need. Released proofs keep their evidence for retry. Reusable rail credentials have rail-specific retention rules; see [KYAPay](/docs/rails/kyapay#stored-data). ## Reconciliation and events [#reconciliation-and-events] Reconciliation examines non-terminal charges older than the configured window. It settles completed reservations, releases uncompleted ones, looks up ambiguous settlement/refund outcomes, and retries only through the rail contract. It reports payments it cannot refund or resolve. It never runs the handler or reconstructs a lost result. Use a shared persistent ledger and a window longer than active handlers and provider visibility delays. Schedule `toll.reconcile()` yourself; the reconciliation CLI remains planned. See [Run reconciliation](/docs/guides/reconciliation). `onEvent` receives `quote.issued`, `authorization.opened`, `charge.moved`, `request.denied`, and `error`. These hooks support application-owned observability; Tollstile adds no telemetry service. ## Conformance and verification limits [#conformance-and-verification-limits] The [conformance kit](/docs/conformance) runs rail contract cases against a fake provider. Ledgers have shared repository suites; adapters and core have their own behavioral tests. Passing fake-provider tests does not verify live provider behavior. The [roadmap](/docs/roadmap) and each rail page state the remaining validation work. # 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. Tollstile and its rails are implemented and tested against fakes, published test vectors, and reference implementations. The API and payment behavior may change before v1.0, and some rails are still experimental and may be unstable in production. Use the test rail during development and introduce live rails gradually. Nothing is on npm yet; 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, execution plans, idempotency, structured denials, 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. `@tollstile/*` 0.1.0 is on npm, but it depends on `tollstile`, which can be published again only after **2026-09-16 10:30 UTC** (as `tollstile@0.1.1`). Until then `npm install` fails with `No versions available for tollstile`. Build from the [repository](https://github.com/tollstile/tollstile) in the meantime. 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. | The [conformance kit](/docs/conformance) is exported from `tollstile/testing`, a subpath of `tollstile`; there is no separate testing package to install. # 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": { "code": "payment_required", "retryable": true, "action": "pay", "message": "Payment required.", "detail": null }, "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] ## Retry the same operation [#retry-the-same-operation] ```bash curl -i -H 'Payment: test' -H 'Idempotency-Key: weather-1' localhost:3000/weather curl -i -H 'Payment: test' -H 'Idempotency-Key: weather-1' localhost:3000/weather ``` The first succeeds; the second returns `409 already_paid`. The default test payer is stable even though `Payment: test` creates a fresh proof. See [Idempotency](/docs/concepts/idempotency). # Roadmap (/docs/roadmap) Tollstile is in public beta. The core payment lifecycle is implemented, but some rails and runtime integrations still need production verification. The v0.1 release includes the implemented areas below; packages are not 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, execution plans and rail exclusion, payer-scoped idempotency and result references, structured denial codes/actions, redaction, reconciliation, events | Implemented | | Conformance | [`railConformance()`, `RailHarness`, `ConformanceCase`](/docs/conformance) from `tollstile/testing`; shared ledger tests | Implemented; fake-provider coverage, with unsupported harness cases reported as skipped | | Policies and requirements | `subscriber`, `credits` with reservations, `payPerCall`, `limit`, `payers`, `when` | Implemented | | Test rail and memory ledger | Failure simulation, `tollstile/testing` contexts and fake clock | 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 error code `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. Keyed charges also hash the reconstructed request body, even on fixed-price routes. Install the appropriate parser for those routes as well. ## 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. ## Idempotent retries [#idempotent-retries] The adapter forwards `Idempotency-Key` to core automatically. Send the same key on the first paid request and its retries; keep the request unchanged. A completed payment returns `409 already_paid` with `chargeId`, `settlement`, and `result`; the handler does not run again. In-flight requests return `409 request_in_progress`, unknown outcomes return `503 payment_outcome_unknown`, and a key used for a different request returns `422 idempotency_key_reused`. Use `payment.fulfill({ resultRef })` to record where your application stored its result. Tollstile does not cache the response. Released or refunded attempts may run again; subscriber grants are not deduplicated. See [Idempotency](/docs/concepts/idempotency) for matching, rail identity scopes, and retry limits. # 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 error code `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. ## Idempotent retries [#idempotent-retries] The adapter forwards `Idempotency-Key` to core automatically. Send the same key on the first paid request and its retries; keep the request unchanged. A completed payment returns `409 already_paid` with `chargeId`, `settlement`, and `result`; the handler does not run again. In-flight requests return `409 request_in_progress`, unknown outcomes return `503 payment_outcome_unknown`, and a key used for a different request returns `422 idempotency_key_reused`. Use `payment.fulfill({ resultRef })` to record where your application stored its result. Tollstile does not cache the response. Released or refunded attempts may run again; subscriber grants are not deduplicated. See [Idempotency](/docs/concepts/idempotency) for matching, rail identity scopes, and retry limits. # 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 error code `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). ## Idempotent retries [#idempotent-retries] The adapter forwards `Idempotency-Key` to core automatically. Send the same key on the first paid request and its retries; keep the request unchanged. A completed payment returns `409 already_paid` with `chargeId`, `settlement`, and `result`; the handler does not run again. In-flight requests return `409 request_in_progress`, unknown outcomes return `503 payment_outcome_unknown`, and a key used for a different request returns `422 idempotency_key_reused`. Use `payment.fulfill({ resultRef })` to record where your application stored its result. Tollstile does not cache the response. Released or refunded attempts may run again; subscriber grants are not deduplicated. See [Idempotency](/docs/concepts/idempotency) for matching, rail identity scopes, and retry limits. # 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. | | `idempotencyKey` | String `_meta["tollstile/idempotency-key"]`, otherwise the HTTP `Idempotency-Key` header, otherwise `null`. | | `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` / `409` / `422` / `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 error code `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 | | No rail receipt (subscriber grant, credit payment, or completion with no settlement) | 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. ## Idempotent tool retries [#idempotent-tool-retries] Send `_meta["tollstile/idempotency-key"]` on the first paid call and every retry. Keep the tool and arguments unchanged; JSON-RPC request IDs may change. A string metadata key takes precedence over the HTTP header. Retries are tool errors containing Tollstile's denial body in `content[0].text` and `_meta["tollstile/payment-required"]`: `already_paid`, `request_in_progress`, `payment_outcome_unknown`, or `idempotency_key_reused`. These are not HTTP status responses or MPP `-32042` challenges. Record `extra.payment.fulfill({ resultRef })` to return a stored result reference in `already_paid`. See [Idempotency](/docs/concepts/idempotency). # 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 error code `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. ## Idempotent retries [#idempotent-retries] The adapter forwards `Idempotency-Key` to core automatically. Send the same key on the first paid request and its retries; keep the request unchanged. A completed payment returns `409 already_paid` with `chargeId`, `settlement`, and `result`; the handler does not run again. In-flight requests return `409 request_in_progress`, unknown outcomes return `503 payment_outcome_unknown`, and a key used for a different request returns `422 idempotency_key_reused`. Use `payment.fulfill({ resultRef })` to record where your application stored its result. Tollstile does not cache the response. Released or refunded attempts may run again; subscriber grants are not deduplicated. See [Idempotency](/docs/concepts/idempotency) for matching, rail identity scopes, and retry limits. # Proxy (any language) (/docs/adapters/proxy) `@tollstile/proxy` is a paid gateway. It prices HTTP routes and MCP tools before they reach your service, forwards paid requests unchanged, and settles on your service's answer. The service needs no Tollstile code, so it can be written in any language. ```txt agent ──► tollstile-proxy ──► your service (private address) 402 · verify · settle · receipt ``` ## Configure [#configure] ```ts title="tollstile.proxy.mjs" import { defineProxyConfig } from "@tollstile/proxy"; import { createTollstile, memoryLedger, testRail, upTo } from "tollstile"; export default defineProxyConfig({ toll: createTollstile({ rails: [testRail()], ledger: memoryLedger() }), upstream: "http://127.0.0.1:8000", mcp: { path: "/mcp" }, routes: [ { method: "GET", path: "/weather", price: "$0.01" }, { method: "POST", path: "/summarize", price: upTo("$0.50") }, { tool: "generate_image", price: "$0.04" }, ], }); ``` ```bash npm install tollstile @tollstile/proxy npx tollstile-proxy --config tollstile.proxy.mjs --port 8402 ``` Routes take the same options as `toll.price()`: `access`, `require`, `flow`, `commit`, `resource`. Paths support `:param` segments and a trailing `*`. Requests no route prices are forwarded free, or refused with `unmatched: "deny"`. ## Your service [#your-service] A FastAPI example with no payment code except two optional headers: ```python title="app/main.py" @app.get("/weather") def weather(request: Request): return {"forecast": "clear", "paid_by": request.headers.get("tollstile-payer")} @app.post("/summarize") async def summarize(request: Request, response: Response): words = len((await request.body()).split()) response.headers["tollstile-fulfill-amount"] = f"${words / 1000:.3f}" # upTo: what this call used return {"words": words} ``` MCP servers built with the MCP Python SDK work as they are: the proxy reads `tools/call` on the MCP endpoint, and every other message passes through. | Header | Direction | Meaning | | ------------------------------------------------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------ | | `Tollstile-Payment-Via`, `Tollstile-Payer`, `Tollstile-Charge-Id`, `Tollstile-Amount` | to your service | Who paid, the charge, and the amount. Client-sent `tollstile-*` headers are removed first | | `Tollstile-Fulfill-Amount` | from your service | On `upTo()` routes, the amount to charge (at most the maximum). Stripped before the client sees the response | | `Tollstile-Result-Ref` | from your service | Where the result is stored, returned to idempotent retries | ## How it decides [#how-it-decides] | Upstream answer | Charge | Client gets | | ----------------------------------------------------------------- | ----------------------------------- | --------------------------------------------- | | HTTP status below 400 (tools: a result without `isError`) | settled before the response is sent | the response, with the rail's receipt | | 400 or above, `isError`, or an invalid `Tollstile-Fulfill-Amount` | released | the response, no receipt | | No answer within `upstreamTimeoutMs` | released | `502 upstream_unavailable` | | Settlement rejected by the provider | failed | a fresh `402`, the upstream response withheld | Unpaid MCP tool calls get a payment-required tool result — x402's `PaymentRequired` when an x402 rail is configured, otherwise Tollstile's denial in `_meta["tollstile/payment-required"]`. Paid results carry the receipt in `_meta`, in JSON and SSE responses. ## Security [#security] Bind the service to `127.0.0.1` or a private network. Anything that reaches it directly skips payment and can forge the `Tollstile-*` headers. * Paths are matched and forwarded in canonical form, so percent-encoding, repeated slashes, or trailing slashes cannot route around a price. * Priced tools inside JSON-RPC batches, and MCP bodies over `maxMcpBodyBytes`, are refused. * `Idempotency-Key` and `_meta["tollstile/idempotency-key"]` work as everywhere: a retried paid request is never paid or forwarded twice. * MCP tool responses are buffered to add the receipt, so progress notifications arrive with the result. HTTP bodies stream after settlement. ## Run anywhere [#run-anywhere] `createProxy()` returns a Web-standard handler, so the gateway also runs on Cloudflare Workers, Deno, and Bun. On Node, `@tollstile/proxy/node` exports `serve(handler, { port })`. ## Verification status [#verification-status] Tested with unit tests and end to end in front of a real FastAPI app with the MCP Python SDK 2.2. Not yet run in production or on Workers. Full example: [`examples/proxy-python`](https://github.com/tollstile/tollstile/tree/main/examples/proxy-python). # 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 applicable decision wins; if a `reserve` decision cannot reserve enough balance, core releases that attempt and continues to the next policy. | 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. Credits participate in [Idempotency](/docs/concepts/idempotency) using the policy account as payer. Subscriber grants create no charge and do not deduplicate the handler. # 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 | # Errors (/docs/concepts/errors) Core denials use a common body shape, so an agent can recover without reading prose. Configuration errors and unexpected exceptions are separate `TollstileError` failures; adapters may rethrow them to the framework. ```json { "error": { "code": "insufficient_authorization", "retryable": true, "action": "pay", "message": "The payment authorizes $0.03; this request costs $0.04.", "detail": null }, "resource": "POST /v1/translate", "required": "$0.04", "authorized": "$0.03" } ``` A payment challenge carries `price`, `variable`, `quote`, `nonce`, `expiresAt`, and `accepts`. A requirement denial with status `402` has the error envelope but no payment offers. A settlement rejection after the handler consumed the body can also lack a fresh quote; request a new challenge before constructing a new proof. | Field | Use | | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `code` | Stable. Branch on it. | | `retryable` | Whether the same logical request can still succeed | | `action` | `pay`: pay using `accepts` in this response. `retry_later`: send the same request with the same payment and idempotency key after `Retry-After`. `fix_request`: change the request. `stop`: do not retry. | | `message` | For humans; may change | | `detail` | The rail's or requirement's own reason, such as `transfer_mismatch`. Log it; do not branch on it. | ## Codes [#codes] | code | Status | action | When | | ---------------------------- | --------------- | ------------------------- | -------------------------------------------------------------------------------------- | | `payment_required` | 402 | pay | No payment | | `quote_required` | 402 | pay | The price is computed per request and the payment carried no quote | | `quote_invalid` | 402 | pay | The quote is forged, expired, or for another resource | | `quote_mismatch` | 402 | pay | The request differs from the one the quote priced | | `quote_offer_missing` | 402 | pay | The quote has no offer for the rail used | | `proof_invalid` | 402 | pay | The rail rejected the payment; `detail` says why | | `insufficient_authorization` | 402 | pay | The payment authorizes less than the price; `required`, `authorized` | | `authorization_expired` | 402 | pay | The payment authorization expired | | `payment_rejected` | 402 | pay | The provider rejected an upfront payment | | `settlement_rejected` | 402 | pay | The provider rejected settlement after the handler; the output was withheld | | `requirement_failed` | 402 · 403 · 429 | pay · stop · retry\_later | A requirement refused; `requirement`, `detail` | | `access_denied` | 403 | stop | No access policy admits the caller | | `proof_already_used` | 409 | stop | A single-use payment was already used by another request | | `request_in_progress` | 409 | retry\_later | Same idempotency key; the first attempt is still running | | `already_paid` | 409 | stop | Same idempotency key; the request was already paid; `chargeId`, `settlement`, `result` | | `idempotency_key_reused` | 422 | fix\_request | Same idempotency key, different request | | `invalid_request` | 400 | fix\_request | Malformed idempotency key or MCP envelope | | `payment_unavailable` | 503 | retry\_later | A provider needed to verify or challenge is down | | `payment_outcome_unknown` | 503 | retry\_later | Settlement outcome unknown; retry with the same payment and key | | `requirement_unavailable` | 503 | retry\_later | A requirement cannot check its evidence right now | Denials include `Cache-Control: no-store`. Only `action: "retry_later"` includes `Retry-After: 5` (including retryable `429`). `already_paid` and `proof_already_used` have `action: "stop"` and no retry header. On MCP, tool-result denials carry the body in `_meta["tollstile/payment-required"]`; MPP challenge errors use the protocol-specific JSON-RPC shape described in [MCP](/docs/adapters/mcp#denials). ## An agent's loop [#an-agents-loop] ```ts const response = await fetch(url, { headers }); if (response.ok) return response; const { error, accepts } = await response.json(); switch (error.action) { case "pay": return payAndRetry(accepts); // new payment, same Idempotency-Key case "retry_later": return retryAfter(response.headers.get("retry-after")); case "fix_request": case "stop": throw new Error(`${error.code}: ${error.message}`); } ``` # 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" })`. The execution plan excludes rails that cannot serve that flow; the route fails where defined if no rail remains. # 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 and recovery reuse operation keys to avoid duplicate settlements and refunds, subject to the rail/provider contract and the verification limits stated on each rail page. Separate requests without an idempotency key can create separate charges. 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 proof must match its quote commitment; the commitment covers the route, request, or custom fields you selected. * 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 runs only once across failures and retries. * 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. # Idempotency (/docs/concepts/idempotency) Agents retry. A response lost to a timeout must not turn into a second payment. ```bash curl -H "Payment: …" -H "Idempotency-Key: 8f1c…" https://api.example.com/v1/translate ``` On MCP, send the key in `_meta["tollstile/idempotency-key"]`. Adapters forward it; there is nothing to configure. ## What a retry gets [#what-a-retry-gets] A retry by the same payer with the same key finds the charge its first attempt created: | First attempt | Retry gets | | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | Still running | `409 request_in_progress`, retry later | | Settlement outcome unknown | `503 payment_outcome_unknown`, retry later | | Settled and fulfillment completed | `409 already_paid` with `chargeId`, `settlement`, and `result`; not charged, not run | | Settlement rejected | `402 settlement_rejected` with a fresh quote | | Released or refunded | A new charge attempt may run after verification and requirements pass | | Settled but fulfillment failed and no automatic refund succeeded | `409 already_paid`; contact the merchant | | Same key, different request | `422 idempotency_key_reused` | * **Keys are scoped to the payer.** A retry that signs a new payment under the same key is still the same request, and the new payment is not charged. One payer cannot occupy another payer's keys. * **Request matching:** `commit: "route"` is strengthened to `"request"` for keyed charges: method, resource, path/query and exact body bytes, or MCP tool and canonical arguments. `commit: "request"` uses that same hash. A custom `commit` function is also used for idempotency matching; include every input that distinguishes the operation. This is separate from the quote commitment. * Keys must contain 1–255 visible ASCII characters (no spaces). Invalid client keys return `400 invalid_request`. * A rail may supply a protocol payment identifier when the client sends none; the client key takes precedence. MPP charge rails use their challenge ID; x402 can use its payment-identifier extension. * Payer identity limits the scope: MPP Stripe uses `stripe:`, and L402 uses `l402:`. A new challenge or invoice changes that scope. KYAPay uses `#`. * The current implementation allows 20 charge attempts per key. After released/refunded attempts exhaust that limit, it returns `422 idempotency_key_reused`. * Verification and requirements can reject a request before charge lookup. A key does not bypass expired credentials, current access rules, or single-use identity nonces. * A rejected settlement remains `failed`; the same key continues to report `settlement_rejected`. After confirming that failure, use a new key and valid proof for a new attempt. ## Without a key [#without-a-key] When neither the client nor the rail supplies a key: * A **single-use** payment presented again while its charge has not been released is refused with `409 proof_already_used`. It is not charged twice, but the retry does not get the original result. * A **reusable** credential (L402, KYAPay, credits) presented again is a new request and **is charged again**. Send a key when retrying reusable credentials. ## Returning the original response [#returning-the-original-response] Tollstile does not store handler responses. Store the result where you like, and record where with `fulfill`: ```ts app.post("/v1/images", tollstile(toll.price("$0.04")), async (c) => { const image = await generate(await c.req.json()); const key = await storage.put(image); await c.get("payment").fulfill({ resultRef: key }); return c.json({ image: storage.url(key) }); }); ``` A retry with the same key gets `409 already_paid` with `"result": ""`, so the agent can fetch what it already paid for without paying or running the handler again. See the [Internal Contract](https://github.com/tollstile/tollstile/blob/main/SPEC.md#11-idempotency) for the normative rules. A `resultRef` must be 1–1024 characters and contain no secrets. It is a reference, not a cached response or proof of delivery. Subscriber grants create no charge and do not deduplicate handler execution. Credits do create charges and use the policy account as the payer scope. # 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. ## Idempotency records [#idempotency-records] The charge ID encodes the payer, idempotency key, and attempt number when a key is present. Repeated creation of that ID returns the existing charge. All instances serving the same payer must share the ledger; a per-process memory ledger cannot deduplicate across instances or restarts. | Charge field | SQL column | Meaning | | ------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | | `requestHash` | `request_hash` (nullable text) | Request commitment for keyed charges; `null` without a key. Core compares it before admitting a keyed retry. | | `resultRef` | `result_ref` (nullable text) | Reference supplied through `payment.fulfill({ resultRef })`, returned as `result` in `already_paid`; no handler response is stored. | Ledgers persist both fields; transitions can update `resultRef`. Keep charge records for the period in which you need retry protection. See [Idempotency](/docs/concepts/idempotency). # Quotes (/docs/concepts/quotes) A payment challenge 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": { "code": "payment_required", "retryable": true, "action": "pay", "message": "Payment required.", "detail": null }, "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] * **A single-use proof pays the quoted price.** 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.** Computed prices default to a commitment over 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 are excluded from computed-price routes. ## 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 error code `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). A requirement denial or a settlement rejection after the body was consumed can have status `402` without a fresh quote. Only compatible, currently available offers appear in `accepts`. Rails without quote support can serve static fixed and static `upTo()` prices when their other capabilities allow it, but not computed prices. # 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. ## Execution plan [#execution-plan] Each route is compiled against your rails when you define it. Rails that cannot serve the route are left out, with the reason; a route no rail can serve is refused at startup. ```ts const toll = createTollstile({ rails: [x402({ ...x402Options, upto: { facilitatorAddress } }), mppStripe(stripeOptions)], ledger, secret, }); const gate = toll.price(upTo("$1.00")); console.log(toll.explain(gate)); ``` ```txt Route upTo("$1.00") pricing: up_to, quote bound to: route access: everyone pays requirements: none rails: x402: authorization flow, settles after handler, single authorization, up-to amounts, release on handler failure excluded: mpp-stripe: needs the authorization flow. The rail supports: upfront. ``` `gate.plan` holds the same information as data, for tests and tooling. ## 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, including its `nonce`, or `null` | | `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; idempotencyKey: string | null; 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()` produces `503 requirement_unavailable` with `error.detail: "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. * Both MPP charge rails are excluded from `upTo()` routes. A route with only these rails is refused; a mixed configuration works if another rail supports variable amounts in the authorization flow. ## 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 retried after successful fulfillment | `409 already_paid` via the challenge ID; no second PaymentIntent | `409 already_paid` via the challenge ID; no second transfer | | Stripe declines, before the handler | `402` with error code `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 error code `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] ## Retries [#retries] Send `Idempotency-Key` on the first paid request and keep it on retries of the same request (on MCP: `_meta["tollstile/idempotency-key"]`). A completed charge returns `409 already_paid`; an in-flight charge returns `409 request_in_progress`; an unknown outcome returns `503 payment_outcome_unknown`. Do not start a new payment while the outcome is unknown. Released/refunded attempts may run again. See [Idempotency](/docs/concepts/idempotency) for request matching and rail-specific key scopes. # 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`. ## Retries [#retries] Send `Idempotency-Key` on the first request and retries. Credit charges use the policy account as payer scope: a completed charge returns `409 already_paid` without another balance reservation or handler call. Without a key, each admitted request spends credits again. Released attempts can run again. See [Idempotency](/docs/concepts/idempotency). # 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" })`. ## Retries [#retries] Send `Idempotency-Key` on the first paid request and keep it on retries of the same request (on MCP: `_meta["tollstile/idempotency-key"]`). A completed charge returns `409 already_paid`; an in-flight charge returns `409 request_in_progress`; an unknown outcome returns `503 payment_outcome_unknown`. Do not start a new payment while the outcome is unknown. Released/refunded attempts may run again. See [Idempotency](/docs/concepts/idempotency) for request matching and rail-specific key scopes. # 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. ## Retries [#retries] Send `Idempotency-Key` on the first paid request and keep it on retries of the same request (on MCP: `_meta["tollstile/idempotency-key"]`). A completed charge returns `409 already_paid`; an in-flight charge returns `409 request_in_progress`; an unknown outcome returns `503 payment_outcome_unknown`. Do not start a new payment while the outcome is unknown. Released/refunded attempts may run again. See [Idempotency](/docs/concepts/idempotency) for request matching and rail-specific key scopes. # 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()]`. ## Retries [#retries] Subscriber grants create no charge, so `Idempotency-Key` does not prevent a free request from running again. Requests that fall through to credits or a rail follow [Idempotency](/docs/concepts/idempotency). # 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, error code `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] ## Retries [#retries] Send `_meta["tollstile/idempotency-key"]` with the first paid call and its retries, alongside the rail proof. Preserve tool arguments. A paid retry returns `already_paid` in the denial metadata, with `chargeId`, `settlement`, and `result`; it does not run the tool again. See [MCP retries](/docs/adapters/mcp#idempotent-tool-retries). # 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. The execution plan excludes incompatible rails, including MPP charge rails. Compatible rails still serve the route; only a route with no compatible rail is refused. Inspect `gate.plan` or `toll.explain(gate)`. ## Retries [#retries] Send `Idempotency-Key` on the first paid request and keep it on retries of the same request (on MCP: `_meta["tollstile/idempotency-key"]`). A completed charge returns `409 already_paid`; an in-flight charge returns `409 request_in_progress`; an unknown outcome returns `503 payment_outcome_unknown`. Do not start a new payment while the outcome is unknown. Released/refunded attempts may run again. See [Idempotency](/docs/concepts/idempotency) for request matching and rail-specific key scopes. # 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 error code `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] ## Retries [#retries] Send `Idempotency-Key` on the first paid request and keep it on retries of the same request (on MCP: `_meta["tollstile/idempotency-key"]`). A completed charge returns `409 already_paid`; an in-flight charge returns `409 request_in_progress`; an unknown outcome returns `503 payment_outcome_unknown`. Do not start a new payment while the outcome is unknown. Released/refunded attempts may run again. See [Idempotency](/docs/concepts/idempotency) for request matching and rail-specific key scopes. # Dynamic pricing with quotes (/docs/guides/dynamic-pricing) ```ts app.post( "/v1/translate", tollstile(toll.price(async (context) => { const words = await countWords(context.request); const micros = BigInt(words) * 100n; return { currency: "USD", micros }; })), 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; rails without quote support are excluded from the execution plan. The route is refused only if no compatible rail remains. ## 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 error code `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] Reusable credentials on quote-capable rails (such as L402) 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. KYAPay cannot carry quotes and is excluded from computed-price routes. If a price function returns `upTo()`, only planned rails with `amounts: "up_to"` can offer that price; if none remain, the request throws `CAPABILITY_MISSING`. ## Retries [#retries] Send `Idempotency-Key` on the first paid request and keep it on retries of the same request (on MCP: `_meta["tollstile/idempotency-key"]`). A completed charge returns `409 already_paid`; an in-flight charge returns `409 request_in_progress`; an unknown outcome returns `503 payment_outcome_unknown`. Do not start a new payment while the outcome is unknown. Released/refunded attempts may run again. See [Idempotency](/docs/concepts/idempotency) for request matching and rail-specific key scopes. # Early Access operations (/docs/guides/early-access-operations) Tollstile does not require a particular cloud, database, or monitoring vendor. Choose the adapter that matches your deployment, then keep the payment state and secrets outside the application process. ## Keep payment state durable [#keep-payment-state-durable] `memoryLedger()` is for local development and tests. Use a durable ledger when more than one process can serve a request or when the process may restart: * [SQLite](/docs/ledgers/sqlite) for a local file, `node:sqlite`, or Cloudflare D1. * [Postgres](/docs/ledgers/postgres) for a shared database used by multiple instances. Apply the ledger schema through your migration process before accepting payments. Back up the database and test restoring it before Early Access. ## Store secrets in the host's secret store [#store-secrets-in-the-hosts-secret-store] Keep quote secrets, provider keys, and facilitator credentials in the secret mechanism supplied by your host. The application should read them as environment variables at startup: ```ts const toll = createTollstile({ rails, ledger, secret: process.env.TOLLSTILE_SECRET, }); ``` Never commit these values, put them in a client bundle, or log request headers. Rotate any key that was exposed during testing. ## Run reconciliation on a schedule [#run-reconciliation-on-a-schedule] Provider timeouts and process crashes can leave charges `unknown`. Run [`toll.reconcile()`](/docs/guides/reconciliation) from a scheduler that is independent of request handling. Pick an interval and `olderThanMs` greater than the slowest handler, and alert when the report has unresolved charges. ```ts const report = await toll.reconcile({ olderThanMs: 15 * 60_000 }); if (report.pending > 0) alertOnCall(report); ``` The scheduler can be a cron job, a Workers Cron trigger, a container task, or another job runner. It must use the same ledger and provider configuration as the API. ## Connect events to monitoring [#connect-events-to-monitoring] Use `onEvent` to forward structured events to the monitoring system you already operate. Tollstile does not choose a vendor or make telemetry calls itself. ```ts const toll = createTollstile({ rails, ledger, onEvent: (event) => monitor.record(event), }); ``` Alert on repeated `payment_unavailable`, `payment_outcome_unknown`, `PROVIDER_TIMEOUT`, and unexpected `already_paid` rates. Redact credentials, signatures, and provider secrets before exporting event data. ## Before inviting Early Access users [#before-inviting-early-access-users] * Run the [conformance kit](/docs/conformance) and the rail's failure tests. * Verify a testnet payment, replay, handler failure, restart, and reconciliation. * Confirm the durable ledger schema is applied and backups are restorable. * Confirm secrets are injected by the host and absent from logs and repositories. * Define an owner and response procedure for provider outages and pending charges. * Publish which rails are verified, experimental, or unavailable in your environment. # 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":{"code":"payment_required","retryable":true,"action":"pay","message":"Payment required: $0.01 for GET /weather.","detail":null}, "resource":"GET /weather","price":"$0.01","variable":false, "quote":"eyJ2IjoxLCJpZCI6…","nonce":"VS_h…","expiresAt":"…", "accepts":[{"rail":"test","asset":{"code":"USD","network":null,"scale":6},"amount":"10000","flow":"authorization",…}]} ``` ```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`, x402 is excluded from an `upTo()` route. The route remains valid when another configured rail supports variable authorization; it is refused only when no compatible rail remains. * On Base Sepolia the x402.org facilitator is the default. On mainnet (`eip155:8453`) and other networks, pass `facilitator: { url, headers }`. * `maxTimeoutSeconds` (default `60`) is how long the payer's signature is valid. The handler and settlement must both finish inside it. Raise it for slow handlers. The test rail and live rails cannot run in one instance. Use one instance per environment. ## Verify with a real client [#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 error code `proof_invalid`; the facilitator's reason is in `error.detail` | | The facilitator is unreachable, times out, or answers `unexpected_verify_error` | `503`; the handler does not run | | The same `PAYMENT-SIGNATURE` sent again | `402`; no second transfer | | The handler throws or answers `400`+ | Released: nothing moves. The same signature can be retried within `maxTimeoutSeconds` | | Settlement rejected after the handler | The output is withheld; the client gets a fresh `402` with error code `settlement_rejected` | | Settlement times out or answers `settlement_pending` | The output is served without a receipt; the charge is `unknown` until `reconcile()` finds the transfer on-chain. `/settle` is never called twice on a hunch | x402 has no refunds. The rail only supports the `authorization` flow, so money moves only after your handler succeeded. ## Next [#next] ## Retries [#retries] Send `Idempotency-Key` on the first paid request and keep it on retries of the same request (on MCP: `_meta["tollstile/idempotency-key"]`). A completed charge returns `409 already_paid`; an in-flight charge returns `409 request_in_progress`; an unknown outcome returns `503 payment_outcome_unknown`. Do not start a new payment while the outcome is unknown. Released/refunded attempts may run again. See [Idempotency](/docs/concepts/idempotency) for request matching and rail-specific key scopes. # 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 error code `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] ## Retries [#retries] Send `Idempotency-Key` on the first paid request and keep it on retries of the same request (on MCP: `_meta["tollstile/idempotency-key"]`). A completed charge returns `409 already_paid`; an in-flight charge returns `409 request_in_progress`; an unknown outcome returns `503 payment_outcome_unknown`. Do not start a new payment while the outcome is unknown. Released/refunded attempts may run again. See [Idempotency](/docs/concepts/idempotency) for request matching and rail-specific key scopes. # 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, charges: [...], errors: [...] } ``` `charges` lists every charge examined with its state before and after; `errors` lists what went wrong, also delivered to `onEvent`. ## From the command line [#from-the-command-line] Export your instance from a module and run it from cron, CI, or by hand: ```ts title="tollstile.config.mjs" import { toll } from "./src/toll.js"; export default toll; ``` ```bash npx tollstile reconcile --older-than 15m ``` ```txt Reconciled 3 charges last updated more than 15m ago chg_5701… GET /report $0.05 unknown/completed → settled/completed chg_9a2c… GET /report $0.05 reserved/running → released/failed chg_c41e… POST /summarize $0.50 unknown/completed · unknown/completed 2 resolved · 1 pending · 1 error ! PROVIDER_UNAVAILABLE chg_c41e…: Provider did not answer within 10000ms … ``` | Option | Meaning | | -------------------- | -------------------------------------------------------------------------------------------------------- | | `--config ` | Default export: the instance, `{ toll }`, or a function returning either. Default `tollstile.config.mjs` | | `--older-than ` | `90s`, `15m`, `2h`, `1d`. Default `15m` — longer than your slowest handler | | `--json` | The report as JSON | | `--fail-on-pending` | Exit `2` while charges stay unresolved, for alerting | Exit codes: `0` done, `1` errors reported, `2` pending with `--fail-on-pending`. It needs a database ledger: a memory ledger in a separate process has nothing to reconcile. Running several workers at once is safe: a charge another worker moves first is left to it. ## 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. ## Retries [#retries] While a keyed charge is `unknown`, retries return `503 payment_outcome_unknown`. After reconciliation records `settled/completed`, a retry returns `409 already_paid` with the stored result reference; reconciliation does not rerun the handler or replay its response. Released/refunded attempts may run again. See [Idempotency](/docs/concepts/idempotency). # 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 error code `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. ## Retries [#retries] Send `Idempotency-Key` on the first paid request and keep it on retries of the same request (on MCP: `_meta["tollstile/idempotency-key"]`). A completed charge returns `409 already_paid`; an in-flight charge returns `409 request_in_progress`; an unknown outcome returns `503 payment_outcome_unknown`. Do not start a new payment while the outcome is unknown. Released/refunded attempts may run again. See [Idempotency](/docs/concepts/idempotency) for request matching and rail-specific key scopes. # 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":{"code":"requirement_failed","retryable":false,"action":"stop","message":"Requirement \"verified-agent\" was not met.","detail":"signature_missing"}, "resource":"GET /weather","requirement":"verified-agent"} ``` 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` and `error.detail` `signature_invalid`; a key missing from the directory fails with `error.detail` `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] ## Retries [#retries] Send the same `Idempotency-Key` for a logical payment retry, but sign each attempt with fresh Web Bot Auth evidence and a fresh nonce. Requirements run before charge lookup, so the key does not bypass `nonce_replayed`. See [Idempotency](/docs/concepts/idempotency). # 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 error code `settlement_rejected` | | Settlement outcome unknown | The response is sent without a receipt; `reconcile()` resolves the charge on-chain | | Completing the payment fails (for example, the ledger is unreachable) | The held response is discarded and the error goes to your Express error handlers | The outcome is decided the moment the response would send its headers, and the response is held until the payment completes. A streaming handler gets its first bytes out only after settlement; an error halfway through a stream does not undo the charge. 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] ## Retries [#retries] Send `Idempotency-Key` on the first paid request and keep it on retries of the same request (on MCP: `_meta["tollstile/idempotency-key"]`). A completed charge returns `409 already_paid`; an in-flight charge returns `409 request_in_progress`; an unknown outcome returns `503 payment_outcome_unknown`. Do not start a new payment while the outcome is unknown. Released/refunded attempts may run again. See [Idempotency](/docs/concepts/idempotency) for request matching and rail-specific key scopes. # 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. ## Idempotency records [#idempotency-records] The charge ID encodes the payer, idempotency key, and attempt number when a key is present. Repeated creation of that ID returns the existing charge. All instances serving the same payer must share the ledger; a per-process memory ledger cannot deduplicate across instances or restarts. | Charge field | SQL column | Meaning | | ------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | | `requestHash` | `request_hash` (nullable text) | Request commitment for keyed charges; `null` without a key. Core compares it before admitting a keyed retry. | | `resultRef` | `result_ref` (nullable text) | Reference supplied through `payment.fulfill({ resultRef })`, returned as `result` in `already_paid`; no handler response is stored. | Ledgers persist both fields; transitions can update `resultRef`. Keep charge records for the period in which you need retry protection. See [Idempotency](/docs/concepts/idempotency). ### Existing databases [#existing-databases] The exported schema creates new tables; `CREATE TABLE IF NOT EXISTS` does not add columns to existing tables. If your database predates `request_hash` and `result_ref`, add the missing nullable text columns through a reviewed migration before running the current ledger. Do not invent request hashes for historical charges: their original requests were not recorded. # 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`. ## Idempotency records [#idempotency-records] The charge ID encodes the payer, idempotency key, and attempt number when a key is present. Repeated creation of that ID returns the existing charge. All instances serving the same payer must share the ledger; a per-process memory ledger cannot deduplicate across instances or restarts. | Charge field | SQL column | Meaning | | ------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | | `requestHash` | `request_hash` (nullable text) | Request commitment for keyed charges; `null` without a key. Core compares it before admitting a keyed retry. | | `resultRef` | `result_ref` (nullable text) | Reference supplied through `payment.fulfill({ resultRef })`, returned as `result` in `already_paid`; no handler response is stored. | Ledgers persist both fields; transitions can update `resultRef`. Keep charge records for the period in which you need retry protection. See [Idempotency](/docs/concepts/idempotency). ### Existing databases [#existing-databases] The exported schema creates new tables; `CREATE TABLE IF NOT EXISTS` does not add columns to existing tables. If your database predates `request_hash` and `result_ref`, add the missing nullable text columns through a reviewed migration before running the current ledger. Do not invent request hashes for historical charges: their original requests were not recorded. # Build a rail (/docs/rails/build-a-rail) A **rail** teaches Tollstile one way to be paid. Core owns pricing, quotes, idempotency, the ledger, and reconciliation; a rail only speaks its protocol. If your protocol needs a core change, that is a gap in the [contract](https://github.com/tollstile/tollstile/blob/main/SPEC.md), not something to work around. This guide builds **Acme**, an imaginary card-style provider: the payer's wallet authorizes a payment against the 402's quote and sends a token; the rail verifies it with Acme, captures after the handler succeeds, and looks captures up when a response is lost. The complete, tested code is in [`examples/custom-rail`](https://github.com/tollstile/tollstile/tree/main/examples/custom-rail). ## 1. Decide what the protocol can do [#1-decide-what-the-protocol-can-do] Answer these before writing code. They become `capabilities`, and routes are [compiled against them](/docs/concepts/rails#execution-plan). | Question | Capability | | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | Does money move before or after the handler? Can it wait? | `flows`: `authorization` (after, preferred) or `upfront` (before; needs refunds) | | Does one proof pay once, or many times up to a limit? | `authorization`: `single` or `reusable` | | Can it settle less than it authorized? | `variableAmount` — enables `upTo()` prices | | Can the payer's proof carry Tollstile's signed quote back, integrity-protected? | `quotes` — enables computed prices | | Can it refund? Partially? | provide `refund()`; `partialRefund` | | Can you ask the provider what happened to a charge? | `lookup()` — **required** | A protocol that cannot look a payment up cannot be a rail: an ambiguous outcome would have to be guessed. ## 2. Write the rail with `createRail()` [#2-write-the-rail-with-createrail] ```ts title="acme-rail.ts" import { createRail, TollstileError } from "tollstile"; export function acmeRail(options: { apiKey: string; fetch?: typeof fetch }) { return createRail<"acme", { paymentId: string }>({ name: "acme", livemode: !options.apiKey.startsWith("sk_test_"), capabilities: { flows: ["authorization"], authorization: "single", quotes: true }, offer: async ({ price }) => /* the amount in your asset, or null */, challenge: async (quote, quoteToken, offer) => /* headers, accepts, mcp */, verify: async (context, terms, operation) => /* absent | invalid | valid */, settle: async (authorization, charge, operation) => /* settled | rejected */, lookup: async (authorization, charge, operation) => /* settled | refunded | none */, refund: async (authorization, charge, operation) => /* refunded | rejected */, receipt: (authorization, charge, context) => /* protocol receipt */, }); } ``` `createRail()` fills safe defaults (no refunds, a no-op `release`, no receipt), refuses declarations no route could use (an `upfront` flow without `refund`), and **checks every `verify` result** — an empty `proofId`, an untrimmed payer, a sentence as a reason, or a quote from a rail that did not declare `quotes` throws `CONFIG_INVALID` instead of admitting the request. ### `offer` — the price in your asset [#offer--the-price-in-your-asset] Return the integer amount in your asset's smallest unit and the basis of conversion. Return `null` when you cannot serve the price (wrong currency, below a minimum); Tollstile leaves the rail out of that 402. Never convert currencies silently: `par` means you configured them as equal; `rate` means a merchant-supplied rate. ### `challenge` — what the payer needs [#challenge--what-the-payer-needs] Put everything a client needs to pay into `accepts` (shown in the 402 body), protocol headers into `headers`, and an MCP form into `mcp` with a `style`. **Carry `quoteToken`** so the payer's proof can return it. If building a challenge calls your provider (an invoice, a payment intent), throw `PROVIDER_UNAVAILABLE` on failure: the rail is omitted instead of breaking the 402. ### `verify` — the security boundary [#verify--the-security-boundary] ```ts async verify(context, terms, operation) { const token = context.request?.headers.get("acme-payment-token"); if (!token) return { status: "absent" }; // not ours: next rail const payment = await acme.verify(token, operation.signal); // throws PROVIDER_* if unreachable if (!payment.valid) return { status: "invalid", reason: payment.reason }; const quote = await terms.openQuote(payment.quote); if (!quote) return { status: "invalid", reason: "quote_invalid", proofId: payment.id }; if (payment.amount !== quote.price.micros.toString()) return { status: "invalid", reason: "amount_mismatch" }; return { status: "valid", proofId: payment.id, // stable: the same payment finds the same authorization payer: payment.payer.toLowerCase(), // canonical: compared exactly quote, limit: quote.price, expiresAt: quote.expiresAt, data: { paymentId: payment.id }, // what settle/lookup/refund need — never a bearer token }; } ``` The rules that keep money safe: * **The server decides the price.** Check amount, asset, network, and recipient against the opened quote or your configuration — never against what the client claims alone. * **`reason` is a stable snake\_case identifier.** Clients see it as `error.detail` under `proof_invalid`. * **Return `proofId` on `invalid`** when the proof is genuine but can no longer be accepted (a used nonce, an expired quote on a real payment). Core then answers a retry of an already-paid request from the ledger instead of asking the client to pay twice. Never set it for proofs you could not authenticate. * **Return `idempotencyKey`** if your protocol carries a per-request payment identifier. * **Throw `PROVIDER_UNAVAILABLE` or `PROVIDER_TIMEOUT`** when you cannot verify right now. The request gets `503`; the handler does not run. ### `settle` — exactly one economic effect [#settle--exactly-one-economic-effect] ```ts async settle(authorization, charge, operation) { const capture = await acme.capture({ paymentId: authorization.data.paymentId, amount: charge.amount.micros.toString(), idempotencyKey: operation.key, // derived from the charge: retries never capture twice signal: operation.signal, }); return capture.ok ? { status: "settled", reference: capture.id, details: {} } : { status: "rejected", reason: capture.error }; } ``` Pass `operation.key` to your provider as its idempotency key. If the provider has none, deduplicate by the charge id yourself. **A timeout or 5xx is not a rejection:** throw `PROVIDER_TIMEOUT`. Tollstile records the charge as `unknown` and calls `lookup` later. ### `lookup` — answer from the provider, never from memory [#lookup--answer-from-the-provider-never-from-memory] Find the effect by what `settle` sent (Acme stores the idempotency key, `":settle"`). Return `none` only when the provider can say nothing happened; if it cannot tell yet, throw `PROVIDER_UNAVAILABLE` and the charge stays `unknown`. ### Evidence and `redact` [#evidence-and-redact] If settling after a crash needs a signed payload, keep it in `data` and implement `redact` to drop it once the charge is final. Tollstile does not redact on `released`, because the same proof may be retried. Bearer tokens that could pay again must never reach `data`. ## 3. Write a fake provider [#3-write-a-fake-provider] The rail must be testable without the network. Write a small in-memory version of the provider's API that behaves like a network service: idempotency keys, lookups, and two faults — **perform the effect but lose the response**, and **fail before any effect**. See [`acme-provider.ts`](https://github.com/tollstile/tollstile/blob/main/examples/custom-rail/src/acme-provider.ts); it is about 100 lines. ## 4. Prove it with the conformance kit [#4-prove-it-with-the-conformance-kit] ```ts title="conformance.test.ts" import { describe, it } from "vitest"; import { fakeClock, railConformance, type RailHarness } from "tollstile/testing"; function harness(): RailHarness { const provider = acmeProvider(); return { rail: acmeRail({ apiKey: "sk_test_acme", fetch: provider.fetch }), clock: fakeClock(), pay: async ({ offer, url }) => { const { amount, currency, quote } = offer.challenge.accepts; const { token } = provider.authorize({ payer: "agent-7", amount, currency, quote }); return new Request(url, { headers: { "acme-payment-token": token } }); }, settlements: () => provider.captures().length, loseNextSettleResponse: () => provider.loseNextCaptureResponse(), failNextSettle: () => provider.failNextCapture(), tamper: (request) => new Request(request.url, { headers: { "acme-payment-token": "tok_forged" } }), }; } describe("acme rail", () => { for (const test of railConformance(harness)) (test.skip ? it.skip : it)(test.name, () => test.run()); }); ``` The kit proves replay safety, tampered proofs, handler failures, settling twice with the same key, lost responses resolved by lookup, failed settlements never recorded as paid, and redaction. It catches real bugs: in the Acme example, sending a random idempotency key instead of `operation.key` fails two cases, because a lost response followed by reconciliation captures twice. See [Conformance test kit](/docs/conformance) for every case. Also check how routes use your rail: ```ts console.log(toll.explain(toll.price("$0.25"))); // acme: authorization flow, settles after handler, single authorization, fixed amounts, release on handler failure toll.price(upTo("$1")); // throws CAPABILITY_MISSING: No configured rail can serve route "upTo("$1")". // - acme: needs variable amounts in the authorization flow. ... ``` Next to other rails, a rail that cannot serve a route is left out of it with that reason instead. ## 5. Before you publish [#5-before-you-publish] * [ ] Every conformance case passes, or is skipped with a reason that holds for your protocol. * [ ] Verified against the provider's sandbox or testnet: a payment, a replay, a handler failure, a lost response reconciled. Record what you ran in your README's **Verification status**. * [ ] Payer ids are canonical and documented (users write them in `payers()` lists). * [ ] No secret, token, or signature appears in errors, events, receipts, or logs. * [ ] Package name `tollstile-rail-` or `@/tollstile-rail-`, with the `tollstile-rail` keyword, `tollstile` as a peer dependency. * [ ] README states capabilities, flows, what `data` stores, retry behavior, and verification status. Built a rail? Get it listed on [Community rails](/docs/rails/community), which also explains how a rail becomes an official `@tollstile/*` package. # Community rails (/docs/rails/community) Anyone can add a payment protocol or provider to Tollstile. A rail is a package that follows the [contract](https://github.com/tollstile/tollstile/blob/main/SPEC.md) and passes the [conformance kit](/docs/conformance); core never needs to change. Start with [Build a rail](/docs/rails/build-a-rail). ## Listed rails [#listed-rails] No community rails are listed yet. Be the first: [list your rail](https://github.com/tollstile/tollstile/issues/new?template=rail-listing.yml). | Package | Protocol or provider | Conformance | Verified against | Maintainer | | ------- | -------------------- | ----------- | ---------------- | ---------- | | — | — | — | — | — | A listing means the checklist below is met. It is not an endorsement: read a rail's README and verification status before it moves your money. ## Three ways to ship a rail [#three-ways-to-ship-a-rail] | | Who uses it | What it takes | | --------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------- | | **In your app** | You | `createRail()` in your code, passed to `createTollstile({ rails })`. Nothing else. | | **Community rail** | Anyone who installs it | Publish it yourself and meet the listing checklist | | **Official `@tollstile/*`** | Everyone who trusts the Tollstile name | Live in this repository and meet the acceptance criteria | Most rails should go in that order: prove it in your own app, publish it, gain users and a sandbox record, then propose it. ## Listing checklist [#listing-checklist] * Named `tollstile-rail-` or `@/tollstile-rail-`, with the `tollstile-rail` keyword * `tollstile` as a peer dependency * `railConformance()` runs in its tests; the README says which cases pass and why any are skipped * The README states capabilities, flows, what `data` stores, retry behavior, and what was verified against the provider's sandbox or testnet * No secrets, tokens, or payer evidence in errors, events, receipts, or logs ## Becoming official [#becoming-official] An official rail carries Tollstile's name, so it must meet all of these: * Every conformance case passes, or is skipped for a reason that holds for the protocol * A recorded run against the provider's sandbox or testnet: a payment, a replay, a handler failure, and a lost response resolved by reconciliation * The protocol has real users and a public specification or API reference * A named maintainer who answers issues for it * It meets SPEC.md: canonical payer ids, amounts checked against the quote or configuration, `proofId` on rejected-but-genuine proofs, provider failures thrown as `PROVIDER_*`, evidence redacted, no secrets anywhere visible * No protocol-specific change to core * A docs page and a complete README Propose it with a [rail proposal](https://github.com/tollstile/tollstile/issues/new?template=rail-proposal.yml). The full policy is in [CONTRIBUTING.md](https://github.com/tollstile/tollstile/blob/main/CONTRIBUTING.md#rails). # 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 = `#`. | | `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**: this rail is excluded from computed-price routes. A route fails to compile only if no configured rail can serve it. Static `upTo()` prices are supported. | | `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 error code `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 a stable `error.code` and the rail-specific reason in `error.detail`. ## 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. ## Retries [#retries] Send a client `Idempotency-Key` (or MCP key). KYAPay supplies no protocol key. Without one, each admitted request using the reusable token creates a new charge. The payer scope is `#`, so the same subject under different issuers has separate keys; a fresh token from the same issuer and subject can find the same keyed charge. Completed payments return `409 already_paid`, running attempts `409 request_in_progress`, and ambiguous outcomes `503 payment_outcome_unknown`. The charge-list ambiguity described above still applies; an idempotency key does not make an unknown provider outcome known. See [Idempotency](/docs/concepts/idempotency). # 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. ## Retries [#retries] L402 credentials are reusable. Send a client `Idempotency-Key` (or MCP key); the rail supplies no protocol key. Without a key, another admitted call consumes more of the credential's limit. With a key, a settled/completed retry returns `409 already_paid` without running the handler or consuming again. The payer is `l402:`. Key scope lasts within that invoice identity; paying a new invoice changes the payer scope. See [Idempotency](/docs/concepts/idempotency). # 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. ## Retries and payer identity [#retries-and-payer-identity] Stripe charge and Tempo charge return their challenge ID as the protocol idempotency key. The client's `Idempotency-Key` (or MCP key) overrides it. After successful fulfillment and settlement, replaying the credential normally returns `409 already_paid`, not a fresh `402`. Running charges return `409 request_in_progress`; unknown outcomes return `503 payment_outcome_unknown`. Verification can still reject an expired or invalid credential before lookup. | Rail | Payer scope | Consequence | | ------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | Stripe charge | `stripe:` | A new challenge is a new payer scope; the same client key does not deduplicate across challenges. | | Tempo charge | `did:pkh:eip155::` | A client key can identify the same operation across fresh proofs from that payer. | | Tempo session | `did:pkh:eip155::` | Reusable; send a client key to avoid consuming another call on a retry. No challenge ID key is supplied by this rail. | A refunded single-use charge permits a new key attempt, but its old authorization remains single-use: a valid fresh authorization may be needed. See [Idempotency](/docs/concepts/idempotency). # 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 paymentId=operation_1 ``` | 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 requirements and idempotency scope. Defaults to `test-payer`. | | `paymentId` | Protocol idempotency key when no client key is supplied. The HTTP or MCP client key takes precedence. | | `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. ## Exercise retries [#exercise-retries] ```bash curl -i -H 'Payment: test proof=p1 payer=agent_1 paymentId=operation_1' localhost:3000/weather curl -i -H 'Payment: test proof=p1 payer=agent_1 paymentId=operation_1' localhost:3000/weather ``` On the fixed-price quickstart route the first call succeeds and the second returns `409 already_paid`. Omit `paymentId` and both client-key carriers to test `409 proof_already_used`. With `authorization: "reusable"`, omitting a key creates another charge instead. `Payment: test` alone uses a fresh proof ID on each request, so it does not test proof replay. See [Idempotency](/docs/concepts/idempotency) and the [conformance kit](/docs/conformance). # 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`, x402 is excluded from `upTo()` routes; the route fails to compile only if no configured rail remains. | | `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 error code `proof_invalid` and the facilitator's reason, sanitized to `[a-z0-9_]`, in `error.detail` | | `/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 error code `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] **Live verification status.** The `exact` flow has been verified on Base Sepolia with x402.org, including a successful USDC transfer, replay rejection, handler failure followed by retry, and persistence across a process restart with the SQLite ledger. `upto`, reconciliation after an ambiguous settlement, and production providers still require separate verification. * 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. ## Retries and payer identity [#retries-and-payer-identity] The payer is the lowercase EVM address. Send `Idempotency-Key` from the first paid request. If absent, the rail can use `extensions["payment-identifier"].info.id` from the payment payload. The HTTP/MCP client key takes precedence. A successfully settled and fulfilled retry with a key returns `409 already_paid`; without either key, a non-released single-use proof returns `409 proof_already_used`. A genuine used proof rejected by the provider can still identify its authorization so core answers from the ledger instead of issuing a new payment challenge. Invalid or unidentifiable proofs still return `402`. In-flight keyed charges return `409 request_in_progress`; unknown outcomes return `503 payment_outcome_unknown`. See [Idempotency](/docs/concepts/idempotency).