Build a rail
Add any payment protocol or provider to Tollstile with createRail(), a fake provider, and the conformance kit — no core changes.
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.
| 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()
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.
reasonis a stable snake_case identifier. Clients see it aserror.detailunderproof_invalid.- Return
proofIdoninvalidwhen 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
idempotencyKeyif your protocol carries a per-request payment identifier. - Throw
PROVIDER_UNAVAILABLEorPROVIDER_TIMEOUTwhen you cannot verify right now. The request gets503; 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
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 thetollstile-railkeyword,tollstileas a peer dependency. - README states capabilities, flows, what
datastores, 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.