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

L402

The L402 (Lightning) rail. The payer pays an invoice once, then presents the macaroon and preimage on every call until the credential's value or lifetime runs out.

Public Beta · early access
npm install tollstile @tollstile/l402
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" }));
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…:<preimage hex>" localhost:3000/weather
# HTTP/1.1 200 OK
# l402-receipt: <payment hash>: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

OptionDefaultPurpose
networkrequiredmainnet, testnet, signet, or regtest. Invoices for another network are refused.
invoicesrequiredAn InvoiceProvider: { createInvoice({ amountMsat, memo, expirySeconds, signal }), lookupInvoice(paymentHash, signal) }. lndRest() is built in.
raterequired(amount: Money) => bigint | Promise<bigint>: 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.
secretrequired32+ 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.
calls1How many calls at the challenged price one credential pays for. The invoice is for price × calls.
credentialTtlMs24 hoursHow long a credential can be used after its challenge.
confirmSettledfalseAlso ask the node on every verification whether the invoice is settled.
invoiceTimeoutMs10 secondsUpper bound for creating an invoice while issuing a challenge.
clocksystem clockFor 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 503s 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

CapabilityValueWhy
flowsauthorizationThe payment happened before the credential was presented. Each call reserves part of its value, runs, then consumes it; a failed handler releases it.
authorizationreusableA credential is used until its value or expiry runs out.
variableAmounttrueConsumption can be any amount up to the reservation, so upTo() prices work.
quotestrueThe quote token is a first-party caveat, tollstile_quote=<token>, in the macaroon minted for that quote.
refund · partialRefundfalseA settled Lightning payment cannot be pulled back.
lookuptrue, always noneSee below.

livemode is true.

Settlement and lookup

Settlement is consumption. settle sends nothing to the node and returns the same reference (<payment hash>:<charge id>) 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=<hex> to the macaroon, which changes its bytes. Keying on the payment hash makes both the same credential.

Verification

  1. Read Authorization: L402 <macaroon>:<preimage> (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

L402 defines no MCP transport. The challenge is { style: "tollstile", rail: "l402", meta: "l402/credential", format: "L402 <macaroon>:<preimage>", 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

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

  • 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

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 <macaroon>:<preimage>. 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

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:<paymentHash>. Key scope lasts within that invoice identity; paying a new invoice changes the payer scope. See Idempotency.

On this page