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

Build a rail

Add any payment protocol or provider to Tollstile with createRail(), a fake provider, and the conformance kit — no core changes.

Public Beta · early access

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, 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.

1. Decide what the protocol can do

Answer these before writing code. They become capabilities, and routes are compiled against them.

QuestionCapability
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()

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

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

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

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

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

Find the effect by what settle sent (Acme stores the idempotency key, "<charge id>: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

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

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; it is about 100 lines.

4. Prove it with the conformance kit

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 for every case.

Also check how routes use your rail:

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

  • 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-<name> or @<scope>/tollstile-rail-<name>, 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, which also explains how a rail becomes an official @tollstile/* package.

On this page