Nostr: npub1mgvlrnf5hm9yf0n5mf9nqmvarhvxkc6remu5ec3vf8r0txqkuk7su0e7q2
Monetise any API with one line of code.
npx @forgesworn/toll-booth demoSpins up a fully working L402-gated joke API on localhost. Mock Lightning backend, in-memory storage, zero configuration. Scan the QR code from your terminal when the free tier runs out.
Prefer a web page, or want to run it against a real wallet? See Run the demo yourself.
Install with npm install @forgesworn/toll-booth express.
import express from 'express'
import { Booth } from '@forgesworn/toll-booth'
import { phoenixdBackend } from '@forgesworn/toll-booth/backends/phoenixd'
const app = express()
const booth = new Booth({
adapter: 'express',
backend: phoenixdBackend({ url: 'http://localhost:9740', password: process.env.PHOENIXD_PASSWORD! }),
pricing: { '/api/resource': 10 }, // 10 sats per request
upstream: 'http://localhost:8080', // your existing API
})
app.get('/invoice-status/:paymentHash', booth.invoiceStatusHandler as express.RequestHandler)
app.post('/create-invoice', booth.createInvoiceHandler as express.RequestHandler)
app.use('/', booth.middleware as express.RequestHandler)
app.listen(3000)| The old way | With toll-booth | |
|---|---|---|
| Step 1 | Create a Stripe account | npm install @forgesworn/toll-booth |
| Step 2 | Verify your identity (KYC) | Set your pricing: { '/api/resource': 10 } |
| Step 3 | Integrate billing SDK | app.use(booth.middleware) |
| Step 4 | Build a sign-up page | Done. No sign-up page needed. |
| Step 5 | Handle webhooks, refunds, chargebacks | Done. Payments are final. |
Zero accounts. Zero API keys. Zero chargebacks. Zero KYC. Zero vendor lock-in.
Your API earns money the moment it receives a request. Clients need no relationship with you: they pay with Lightning, Cashu ecash, LNURLcash notes, x402 stablecoins or IETF Payment, whichever rails you enable. Payments settle instantly and are cryptographically final: no disputes, no reversals, no Stripe risk reviews.
satgate is a pay-per-token AI inference proxy built on toll-booth. It monetises any OpenAI-compatible endpoint — Ollama, vLLM, llama.cpp — with one command. Token counting, model pricing, streaming reconciliation, capacity management. Everything else — payments, credits, free tier, macaroon auth — is toll-booth.
~400 lines of product logic on top of the middleware. That's what "monetise any API with one line of code" looks like in practice.
toll-booth is the server side of a two-part stack for machine-to-machine payments. 402-mcp is the client side - an MCP server that gives AI agents the ability to discover, pay, and consume L402-gated APIs autonomously.
AI Agent -> 402-mcp -> toll-booth -> Your API
An agent using Claude, GPT, or any MCP-capable model can call your API, receive a 402 payment challenge, pay the Lightning invoice from its wallet, and retry - all without human intervention. No OAuth dance, no API key rotation, no billing portal.
Pay 21 sats, get a joke. No account. No sign-up. The hosted version has been retired, and it was only ever the sats-for-laughs example, so you can run the same thing yourself.
1. One command, no setup. Mock Lightning, in-memory storage, on http://localhost:3000:
npx @forgesworn/toll-booth demo2. The full app locally. examples/sats-for-laughs adds a web page with QR codes and wallet buttons, tiered pricing and a joke pool. Mock mode auto-settles invoices:
git clone https://github.com/forgesworn/toll-booth.git && cd toll-booth
npm install && npm run build
cd examples/sats-for-laughs && npm install
MOCK=true npm start # http://localhost:30003. Deploy your own with a real backend. Docker Compose runs the app next to Phoenixd. Set ROOT_KEY and the Phoenixd password in .env; the example README has the exact steps. To use a wallet you already run instead, point NWC_URI_FILE at a file holding a Nostr Wallet Connect URI.
Then walk through the flow:
# Three free jokes per day per IP
curl http://localhost:3000/api/joke
# Free tier exhausted (HTTP 402) - request a Lightning invoice for 21 sats
curl -X POST http://localhost:3000/create-invoice
# Mock backend: the invoice settles in about a second. Read the preimage from the
# payment_url in the response above (with a real backend, pay the invoice first)
curl http://localhost:3000<payment_url>
# Authenticate with the macaroon from the invoice response and the preimage
curl -H "Authorization: L402 <macaroon>:<preimage>" http://localhost:3000/api/joke- L402 protocol - industry-standard HTTP 402 payment flow with macaroon credentials
- Multiple Lightning backends - Phoenixd, LND, CLN, LNbits, NWC (any Nostr Wallet Connect wallet)
- Alternative payment methods - Cashu ecash tokens and xcashu (NUT-24) direct-header payments
- LNURLcash bearer notes - accepts a note URL as specified by the proposed LUD-25 (lnurl/luds#301) in an
X-LNURLcashheader. One rotate at the mint is the verification, the double-spend check and the transfer of ownership. - IETF Payment authentication - implements draft-ryan-httpauth-payment-01, an expired IETF Internet-Draft for HTTP payment authentication. HMAC-bound stateless challenges with Lightning settlement.
- x402 stablecoin payments - accepts x402 on-chain stablecoin payments (USDC on Base, Polygon) alongside Lightning and Cashu simultaneously
- Cashu-only mode - no Lightning node required; ideal for serverless and edge deployments
- Credit system - pre-paid balance with volume discount tiers
- Free tier - configurable daily allowance (IP-hashed, no PII stored)
- Privacy by design - no personal data collected or stored; IP addresses are one-way hashed with a daily-rotating salt before any processing
- Self-service payment page - QR codes, tier selector, wallet adapter buttons
- SQLite persistence - WAL mode, automatic invoice expiry pruning
- Three framework adapters - Express, Web Standard (Deno/Bun/Workers), and Hono
- Framework-agnostic core - use the
Boothfacade or wire handlers directly
npm install @forgesworn/toll-boothexpress and hono are optional peer dependencies and are not installed with toll-booth. Install the one you use: npm install express (and npm install -D @types/express for TypeScript) or npm install hono. The Web Standard adapter needs neither. The snippets after the framework sections are fragments: they reuse Booth, phoenixdBackend, express and app from the Express example above.
import express from 'express'
import { Booth } from '@forgesworn/toll-booth'
import { phoenixdBackend } from '@forgesworn/toll-booth/backends/phoenixd'
const app = express()
app.use(express.json())
const booth = new Booth({
adapter: 'express',
backend: phoenixdBackend({
url: 'http://localhost:9740',
password: process.env.PHOENIXD_PASSWORD!,
}),
pricing: { '/api/resource': 10 }, // 10 sats per request
upstream: 'http://localhost:8080', // your API
rootKey: process.env.ROOT_KEY, // 64 hex chars, required for production
})
app.get('/invoice-status/:paymentHash', booth.invoiceStatusHandler as express.RequestHandler)
app.post('/create-invoice', booth.createInvoiceHandler as express.RequestHandler)
app.use('/', booth.middleware as express.RequestHandler)
app.listen(3000)import { Booth } from '@forgesworn/toll-booth'
import { lndBackend } from '@forgesworn/toll-booth/backends/lnd'
const booth = new Booth({
adapter: 'web-standard',
backend: lndBackend({
url: 'https://localhost:8080',
macaroon: process.env.LND_MACAROON!,
}),
pricing: { '/api/resource': 5 },
upstream: 'http://localhost:8080',
})
// Deno example
Deno.serve({ port: 3000 }, async (req: Request) => {
const url = new URL(req.url)
if (url.pathname.startsWith('/invoice-status/'))
return booth.invoiceStatusHandler(req)
if (url.pathname === '/create-invoice' && req.method === 'POST')
return booth.createInvoiceHandler(req)
return booth.middleware(req)
})npm install @forgesworn/toll-booth honoimport { Hono } from 'hono'
import { createHonoTollBooth, type TollBoothEnv } from '@forgesworn/toll-booth/hono'
import { phoenixdBackend } from '@forgesworn/toll-booth/backends/phoenixd'
import { createTollBooth } from '@forgesworn/toll-booth'
import { sqliteStorage } from '@forgesworn/toll-booth/storage/sqlite'
const storage = sqliteStorage({ path: './toll-booth.db' })
const backend = phoenixdBackend({ url: 'http://localhost:9740', password: process.env.PHOENIXD_PASSWORD! })
const rootKey = process.env.ROOT_KEY!
const defaultInvoiceAmount = 1000
const engine = createTollBooth({
backend,
storage,
pricing: { '/api/resource': 10 },
upstream: 'http://localhost:8080',
rootKey,
defaultInvoiceAmount,
})
const tollBooth = createHonoTollBooth({ engine })
const app = new Hono<TollBoothEnv>()
// Mount payment routes
app.route('/', tollBooth.createPaymentApp({
backend, // creates the Lightning invoices
storage, // the same instance the engine uses
rootKey, // the same value the engine uses
tiers: [],
defaultAmount: defaultInvoiceAmount,
}))
// Gate your API
app.use('/api/*', tollBooth.authMiddleware)
app.get('/api/resource', (c) => {
const balance = c.get('tollBoothCreditBalance')
return c.json({ message: 'Paid content', balance })
})
export default appcreatePaymentApp() names its default amount defaultAmount; it is the same setting as defaultInvoiceAmount on Booth and createTollBooth(), so pass the same number to both. Pass the same storage instance and rootKey value to createTollBooth() and createPaymentApp(): the payment app mints macaroons with that key and records invoices in that storage, and the auth middleware can only verify what it can read back. See docs/configuration.md.
npm install @forgesworn/toll-booth @cashu/cashu-tsimport { Booth } from '@forgesworn/toll-booth'
import { Wallet, getTokenMetadata, sumProofs } from '@cashu/cashu-ts'
// Keep this in durable storage in production: crash recovery replays redeemCashu
// for the same paymentHash and expects the same answer.
const redeemed = new Map<string, number>()
const booth = new Booth({
adapter: 'web-standard',
redeemCashu: async (token, paymentHash) => {
const previous = redeemed.get(paymentHash)
if (previous !== undefined) return previous
// Swap the proofs at the mint that issued the token; the mint is the double-spend check
const wallet = new Wallet(getTokenMetadata(token).mint, { unit: 'sat' })
await wallet.loadMint()
const proofs = await wallet.receive(token)
const amountRedeemed = sumProofs(proofs).toNumber() // satoshis
redeemed.set(paymentHash, amountRedeemed)
return amountRedeemed
},
pricing: { '/api/resource': 5 },
upstream: 'http://localhost:8080',
})No Lightning node, no channels, no liquidity management. Ideal for serverless and edge deployments.
import { Booth } from '@forgesworn/toll-booth'
const booth = new Booth({
adapter: 'web-standard',
xcashu: {
mints: ['https://mint.minibits.cash'],
unit: 'sat',
},
pricing: { '/api/resource': 10 },
upstream: 'http://localhost:3000',
})Clients pay by sending X-Cashu: cashuB... tokens in the request header. Proofs are verified and swapped at the configured mint(s) using cashu-ts.
Unlike the redeemCashu callback (which integrates Cashu into the L402 payment-and-redeem flow), xcashu is a self-contained payment rail: the client attaches a token directly to the API request and gets access in one step — no separate redeem endpoint required. Both rails can run simultaneously; the 402 challenge will include both WWW-Authenticate (L402) and X-Cashu headers.
import { Booth, meltNoteToLightning } from '@forgesworn/toll-booth'
import { phoenixdBackend } from '@forgesworn/toll-booth/backends/phoenixd'
// Your own Lightning node, used here only to sweep notes onto it
const myNode = phoenixdBackend({ url: 'http://localhost:9740', password: process.env.PHOENIXD_PASSWORD! })
const booth = new Booth({
adapter: 'web-standard',
lnurlcash: {
mints: ['mint.example.com'],
// Optional: sweep every note you take straight onto your own node.
onNoteReceived: async note => {
await meltNoteToLightning({
noteUrl: note.url,
createInvoice: async sats => (await myNode.createInvoice(sats)).bolt11,
})
},
},
pricing: { '/api/resource': 10 },
upstream: 'http://localhost:3000',
})Clients pay by sending a bearer note URL in the request header, in either spelling:
X-LNURLcash: lnurlw://mint.example.com/w?k1=<64 hex>&c=cs<amount>1...
X-LNURLcash: https://mint.example.com/w?k1=<64 hex>&amount=21000&sig=<130 hex>
Notes in the shape LUD-25 has used since 29 Sep 2026 (certificate c = cs1<amount> over the note's taproot key) and in the shape before it (sig = hex over the note's hash, alongside amount) are both accepted.
An unpaid request is answered with 402 carrying the same charge twice, each in its own settled shape. The body's lnurlcash section is the charge request the published JSON Schema for this payment method validates: amount as a decimal string of sats, currency, and methodDetails.mints. The X-LNURLcash header is that same charge as a payment request, lnurlcashreq1 followed by unpadded base64url of its RFC 8785 canonical JSON, so it also carries v and an id handle that a bare charge request has no room for. That is the form the conformance vectors pin.
The booth settles a note by rotating it at the mint: it generates a fresh secret of its own, sends only that secret's hash, and the mint moves the note's value onto it. That single call does three jobs at once - it proves the note is live, it burns the secret the client presented, and it makes the booth the sole owner of the replacement. There is no local replay table to keep, because a replayed note is refused by the mint. The mint is also the authority on what a note is worth: the amount in the URL is a hint and is never trusted.
Mint compatibility. The booth speaks to a mint in the forms every generation reads, so it works with current mints and with those that predate the 29 Sep 2026 revision of LUD-25 (including ones that only understand h/k1): a note is looked up by its own k1, and the rotate names the new note's 64-hex hash as both p1 (the current name) and h (the older one). Notes handed to onNoteReceived carry the mint's certificate as c when the mint certifies the current way, and as sig with amount when it certifies the older way. meltNoteToLightning melts a note in any of those shapes, including notes handed over by earlier versions of toll-booth.
Configuration:
| Option | Description |
|---|---|
mints |
Accepted mints (1+), each a host or any URL on that host. Notes from anywhere else are refused before any network call. |
unit |
'sat' (the only value; notes are sat-denominated). |
requireSignature |
Require the note URL's certificate (c, or the older sig) to verify against the mint's advertised pubkey, under the rule for its shape: cs1<amount> over the note's taproot key, or hex over its hash. A superseded certificate on a key-path spend has no offline rule and is left to the mint. The replacement note must also come back certified. Off by default - a mint with no funding source of its own issues unsigned notes. |
timeoutMs |
Leash on calls to the mint, default 10000. Verification happens on the request path, so a slow mint must not hold your caller open. |
onNoteReceived |
Fire-and-forget callback handed the settled note ({url, k1, amountMsat, host}). Melt it, persist it, or forward it. |
A note pays for the request and the surplus becomes credit against that settlement, reported in X-Credit-Balance. The payer cannot spend that surplus later, so a client should present a note close to the price - splitting one at the mint first if it needs to.
No Lightning node is required. meltNoteToLightning({noteUrl, createInvoice}) sweeps a note onto your own node when you do have one; it returns as soon as the mint accepts the melt, with a LUD-21 verifyUrl to poll, because the note is only burned once the outgoing payment actually settles.
import { Booth } from '@forgesworn/toll-booth'
import { phoenixdBackend } from '@forgesworn/toll-booth/backends/phoenixd'
const booth = new Booth({
adapter: 'express',
backend: phoenixdBackend({ url: 'http://localhost:9740', password: process.env.PHOENIXD_PASSWORD! }),
ietfPayment: {
realm: 'api.example.com',
// hmacSecret auto-derived from rootKey if omitted
},
pricing: { '/api/resource': 10 },
upstream: 'http://localhost:8080',
})Implements the IETF Payment authentication scheme from draft-ryan-httpauth-payment-01, an expired IETF Internet-Draft (not a standard). Challenges are stateless (HMAC-SHA256 bound, no database lookup on verify), with JCS-encoded charge requests and timing-safe validation. The 402 response includes a WWW-Authenticate: Payment header alongside the L402 challenge, so clients can use whichever scheme they support.
- satgate: Pay-per-token AI inference
- Sats for Laughs: From zero to production L402 API (the hosted demo, retired October 2026)
import { phoenixdBackend } from '@forgesworn/toll-booth/backends/phoenixd'
import { lndBackend } from '@forgesworn/toll-booth/backends/lnd'
import { clnBackend } from '@forgesworn/toll-booth/backends/cln'
import { lnbitsBackend } from '@forgesworn/toll-booth/backends/lnbits'
import { nwcBackend } from '@forgesworn/toll-booth/backends/nwc'Each backend implements the LightningBackend interface (createInvoice + checkInvoice).
| Backend | Status | Notes |
|---|---|---|
| Phoenixd | Stable | Simplest self-hosted option |
| LND | Stable | Industry standard |
| CLN | Stable | Core Lightning REST API |
| LNbits | Stable | Any LNbits instance - self-hosted or hosted |
| NWC | Stable | Any Nostr Wallet Connect wallet (Alby Hub, Umbrel, Phoenix, etc.) — E2E encrypted via NIP-44 |
Aperture is Lightning Labs' production L402 reverse proxy. It's battle-tested and feature-rich. Use it if you can.
| Aperture | toll-booth | |
|---|---|---|
| Language | Go binary | TypeScript middleware |
| Deployment | Standalone reverse proxy | Embeds in your app, or runs as a gateway in front of any HTTP service |
| Lightning node | Requires LND | Phoenixd, LND, CLN, LNbits, NWC, or none (ecash-only) |
| Payment rails | Lightning only | Lightning, Cashu ecash, xcashu (NUT-24), LNURLcash bearer notes (proposed LUD-25, lnurl/luds#301), x402 stablecoins, IETF Payment - simultaneously |
| IETF Payment | No | Yes - draft-ryan-httpauth-payment-01 with stateless HMAC challenges |
| x402 stablecoins | No | Yes - USDC on Base, Polygon via pluggable facilitator |
| Cashu ecash | No | Yes - redeemCashu callback + xcashu (NUT-24) direct-header rail |
| LNURLcash notes | No | Yes - proposed LUD-25 bearer notes in an X-LNURLcash header, settled with one rotate |
| Credit system | No | Pre-paid balance with volume discount tiers |
| Framework adapters | N/A (standalone proxy) | Express, Web Standard (Deno/Bun/Workers), Hono |
| Serverless | No - long-running process | Yes - Web Standard adapter runs on Cloudflare Workers, Deno, Bun |
| Configuration | YAML file | Programmatic (code) |
For a detailed comparison with all alternatives, see docs/comparison.md.
x402 is Coinbase's HTTP 402 payment protocol for on-chain stablecoins. toll-booth speaks it natively. A single deployment accepts Lightning and x402 stablecoins and Cashu — simultaneously. The seller doesn't care how they get paid. They just want paid.
import { Booth, type X402Facilitator } from '@forgesworn/toll-booth'
import { phoenixdBackend } from '@forgesworn/toll-booth/backends/phoenixd'
// You supply the settlement check. `verify` must confirm on-chain that `payload`
// paid `requirements.amount` of `requirements.asset` on `requirements.network`
// to `requirements.payTo`. This placeholder fails closed: it accepts nothing
// until you replace it with a call to your facilitator or chain node.
const myFacilitator: X402Facilitator = {
async verify(payload, _requirements) {
return { valid: false, txHash: '', amount: 0, sender: payload.sender }
},
}
const booth = new Booth({
adapter: 'express',
backend: phoenixdBackend({ url: 'http://localhost:9740', password: process.env.PHOENIXD_PASSWORD! }),
x402: {
receiverAddress: '0x1234...abcd',
network: 'base',
facilitator: myFacilitator, // verifies on-chain settlement
},
pricing: { '/api/resource': { sats: 10, usd: 5 } }, // price in both currencies
upstream: 'http://localhost:8080',
})The 402 challenge includes both WWW-Authenticate (L402) and Payment-Required (x402) headers. Clients choose their preferred rail. x402 payments settle into the same credit system as Lightning — volume discount tiers apply regardless of payment method.
The unique value of toll-booth isn't any single payment rail. It's the middleware layer: gating, credit accounting, free tiers, volume discounts, upstream proxying, and macaroon credentials — all framework-agnostic, all runtime-agnostic, all payment-rail agnostic. Payment protocols are pluggable rails. toll-booth is the booth.
toll-booth works as a reverse proxy gateway, so the upstream API can be written in any language - C#, Go, Python, Ruby, Java, or anything else that speaks HTTP. The upstream service doesn't need to know about L402 or Lightning; it just receives normal requests.
Client ---> toll-booth (Node.js) ---> Your API (any language)
| |
L402 payment gating Plain HTTP requests
Macaroon verification X-Credit-Balance header added
Point upstream at your existing service:
const booth = new Booth({
adapter: 'express',
backend: phoenixdBackend({ url: 'http://localhost:9740', password: process.env.PHOENIXD_PASSWORD! }),
pricing: { '/api/search': 5, '/api/generate': 20 },
upstream: 'http://my-dotnet-api:5000', // ASP.NET, FastAPI, Gin, Rails...
})Deploy toll-booth as a sidecar (Docker Compose, Kubernetes) or as a standalone gateway in front of multiple services. See examples/valhalla-proxy/ for a complete Docker Compose reference - the Valhalla routing engine it gates is a C++ service.
- Set a persistent
rootKey(64 hex chars / 32 bytes). Without it, a random key is generated per restart and all existing macaroons become invalid. Generate one with:node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" - Use a persistent database. With
Booth, setdbPath(default:./toll-booth.db; mutually exclusive withstorage). WithcreateTollBooth(), pass astoragesuch assqliteStorage({ path }). See docs/configuration.md. - Enable
strictPricing: trueto prevent unpriced routes from bypassing billing. - Ensure your
pricingkeys match the paths the middleware actually sees (after mounting). Each adapter computes one canonical path, prices the request on it and forwards exactly that path upstream: dot segments are resolved, percent-encoded unreserved characters are decoded, duplicate slashes are collapsed, and encoded/,\or.segments and literal backslashes are rejected with 400. Pricing lookups ignore a trailing slash and are case-insensitive, so/API/jokeis charged as/api/joke(case-insensitive upstreams such as Express would otherwise serve it for free); the path is forwarded with its original case. - Set
trustProxy: truewhen behind a reverse proxy, or provide agetClientIpcallback for per-client free-tier isolation. - If you implement
redeemCashu, make it idempotent for the samepaymentHash- crash recovery depends on it. - Rate-limit
/create-invoiceat your reverse proxy - each call creates a real Lightning invoice. - You can also enable
invoiceRateLimit.maxPendingPerIpin the Booth config to cap pending unpaid invoices per client IP. The cap applies to bothPOST /create-invoiceand the 402 challenge issued by any priced endpoint — over the cap, the engine returns HTTP 429 (Retry-After: 3600) instead of minting another invoice.
examples/sats-for-laughs/ is the fastest path from "I have an API" to "my API earns sats". It is the code the hosted joke demo ran on until October 2026, so you can run your own. Includes a web frontend with QR codes and wallet adapter buttons, plus a JSON API for programmatic access. Clone it, change three env vars, deploy.
cd examples/sats-for-laughs
cp .env.example .env # add your Phoenixd credentials
docker compose up -d # or: MOCK=true npm startIncludes mock mode for local development (auto-settles invoices, no Lightning node needed), Docker Compose with Phoenixd, and a pre-generated pool of 100+ jokes across six topics.
examples/valhalla-proxy/ gates the Valhalla routing engine (a C++ service) behind Lightning payments. Full Docker Compose setup demonstrating toll-booth as a sidecar proxy in front of non-JavaScript infrastructure.
sequenceDiagram
participant C as Client
participant T as toll-booth
participant U as Upstream API
C->>T: GET /api/resource
T->>T: Check free tier
alt Free tier available
T->>U: Proxy request
U-->>T: Response
T-->>C: 200 + X-Free-Remaining header
else Free tier exhausted
T-->>C: 402 + Invoice + Macaroon
C->>C: Pay the invoice (or redeem Cashu)
C->>T: GET /api/resource<br/>Authorization: L402 macaroon:preimage
T->>T: Verify macaroon + preimage
T->>T: Settle credit + debit cost
T->>U: Proxy request
U-->>T: Response
T-->>C: 200 + X-Credit-Balance header
end
- Client requests a priced endpoint without credentials
- Free tier checked - if allowance remains, request passes through
- If exhausted - 402 response with BOLT-11 invoice + macaroon
- Client pays the Lightning invoice and gets its preimage, or redeems a Cashu token at
/cashu-redeem(whenredeemCashuis set) and gets a settlement secret. (NWC is how the operator's own Lightning backend can be reached, not a way for clients to pay.) - Client sends
Authorization: L402 <macaroon>:<preimage>, with the settlement secret in place of the preimage after a Cashu redeem - Macaroon verified, credit deducted, request proxied upstream
This is the L402 flow. The other rails (xcashu, LNURLcash, x402 and IETF Payment) answer the same 402 with their own headers instead of steps 4 to 6; see their sections.
Three optional callbacks let you observe the payment lifecycle without modifying the core flow:
const booth = new Booth({
adapter: 'express',
backend: phoenixdBackend({ url: 'http://localhost:9740', password: process.env.PHOENIXD_PASSWORD! }),
pricing: { '/api/resource': 10 },
upstream: 'http://localhost:8080',
// Fired once per payment hash on first successful authentication
onPayment: (event) => {
console.log(`Received ${event.amountSats} sats via ${event.rail} (${event.paymentHash})`)
// event: { timestamp, paymentHash, amountSats, currency, rail }
},
// Fired on every authenticated or free-tier request
onRequest: (event) => {
console.log(`${event.endpoint} -${event.satsDeducted} sats, ${event.remainingBalance} remaining`)
// event: { timestamp, endpoint, satsDeducted, remainingBalance, latencyMs, authenticated, currency, tier }
},
// Fired when a 402 payment challenge is issued
onChallenge: (event) => {
console.log(`Challenged ${event.endpoint} for ${event.amountSats} sats`)
// event: { timestamp, endpoint, amountSats }
},
})Use these for logging, analytics, webhook dispatch, or feeding a metrics pipeline. They fire synchronously in the request path, so keep them fast. For Hono, these same callbacks are available in the createTollBooth() engine config.
toll-booth only gates routes that appear in the pricing map. Unpriced routes pass through free by default.
const booth = new Booth({
adapter: 'express',
backend: phoenixdBackend({ url: 'http://localhost:9740', password: process.env.PHOENIXD_PASSWORD! }),
pricing: {
'/api/premium': 100, // 100 sats per request
'/api/generate': 50, // 50 sats per request
},
upstream: 'http://localhost:8080',
})
app.get('/invoice-status/:paymentHash', booth.invoiceStatusHandler as express.RequestHandler)
app.post('/create-invoice', booth.createInvoiceHandler as express.RequestHandler)
app.use('/', booth.middleware as express.RequestHandler)
// /api/premium and /api/generate are gated (402 if no credits)
// /api/search, /health, /docs, etc. pass through freeAlternatively, mount the middleware on a specific path prefix so it only runs for a subset of routes:
// Only requests under /api/paid go through toll-booth
app.use('/api/paid', booth.middleware as express.RequestHandler)
// These routes are completely unaffected
app.get('/api/free', (req, res) => res.json({ free: true }))
app.get('/health', (req, res) => res.json({ ok: true }))To gate everything by default (opt-out instead of opt-in), enable strictPricing. Any route not in the pricing map will receive a 402 challenge at the defaultInvoiceAmount:
const booth = new Booth({
adapter: 'express',
backend: phoenixdBackend({ url: 'http://localhost:9740', password: process.env.PHOENIXD_PASSWORD! }),
pricing: { '/health': 0 }, // explicitly free
strictPricing: true, // everything else costs defaultInvoiceAmount
defaultInvoiceAmount: 10,
upstream: 'http://localhost:8080',
})Add application-specific restrictions to macaroons via the /create-invoice endpoint. Custom caveats are forwarded to your upstream service as X-Toll-Caveat-* request headers, so your API can enforce them.
Caveats restrict access; they never grant it. Whoever requests the invoice chooses its caveats, so a caveat can only narrow what a macaroon may do. Never unlock anything because a caveat is present (for example, do not serve premium features because
X-Toll-Caveat-Tiersayspremium); charge for that with tiered pricing (for example{ default: 5, premium: 42 }) instead. toll-booth strips anyX-Toll-*,X-Credit-Balance,X-Free-RemainingorX-Session-Balanceheader the client sends, so the values your upstream sees always come from a verified macaroon.
# Request an invoice with custom caveats. The expiry must be in the future,
# so compute it (here 30 days ahead) rather than pasting a fixed date.
EXPIRES=$(node -e "console.log(new Date(Date.now() + 30 * 86400000).toISOString())")
curl -X POST https://api.example.com/create-invoice \
-H 'Content-Type: application/json' \
-d "{
\"amountSats\": 1000,
\"caveats\": [\"model = llama3\", \"tier = basic\", \"expires = $EXPIRES\"]
}"When the client authenticates with this macaroon, toll-booth parses the caveats and forwards them as headers to your upstream:
X-Toll-Caveat-Model: llama3
X-Toll-Caveat-Tier: basic
Your upstream API reads these headers to narrow what the request may do. Here's a complete Express example with validation and error handling:
app.get('/api/generate', (req, res) => {
// Read custom caveats from toll-booth headers
const model = req.headers['x-toll-caveat-model'] as string | undefined
const tier = req.headers['x-toll-caveat-tier'] as string | undefined
const balance = Number(req.headers['x-credit-balance'] ?? 0)
// A model caveat pins the macaroon to one model
const requested = req.body.model ?? 'llama3'
if (model && requested !== model) {
return res.status(403).json({ error: `This macaroon is restricted to model "${model}"` })
}
// A basic-tier caveat removes streaming; its absence grants nothing extra
if (tier === 'basic' && req.body.stream) {
return res.status(403).json({ error: 'Streaming not available on basic tier' })
}
// ... proceed with generation using `requested`
})For Hono, the auth middleware sets the same headers on the request seen by downstream handlers, alongside its context variables:
app.get('/api/generate', (c) => {
const balance = c.get('tollBoothCreditBalance')
const hash = c.get('tollBoothPaymentHash')
// Verified caveats are on the request headers; client-sent copies are stripped
const model = c.req.header('x-toll-caveat-model')
// ... enforce as needed
})Built-in caveats are verified automatically by toll-booth during macaroon verification. The client never reaches your upstream if these fail:
| Caveat | Effect | Example |
|---|---|---|
route = /api/* |
Restrict the macaroon to specific paths. Supports /* prefix matching. |
Only allows requests under /api/ |
expires = <ISO 8601 UTC timestamp> |
Time-limited access. Rejected once the timestamp has passed, so use a time in the future. | 30-day access pass: set it 30 days ahead |
ip = 203.0.113.1 |
Bind the macaroon to a specific client IP. | Prevent credential sharing |
Custom caveats (any key not in the reserved list) are parsed by toll-booth and forwarded to your upstream as X-Toll-Caveat-* request headers. Your upstream is responsible for enforcing them, as restrictions only. Up to 16 custom caveats per macaroon, max 1024 characters each.
Reserved caveat keys (payment_hash, credit_balance, currency) cannot be set via the API; they are managed internally by toll-booth.
Caveats are cryptographically bound to the macaroon's HMAC chain. A client cannot remove or modify existing caveats. However, macaroons allow anyone to append additional caveats (which can only narrow permissions, never widen them). toll-booth handles this safely:
- Duplicate caveats are rejected during verification
- First-occurrence-wins parsing ensures server-set values take precedence over any appended duplicates
- Caveat values are sanitised (newlines stripped to prevent header injection)
After successful authentication, payment details are available to downstream code.
Hono handlers access payment state via typed context variables:
import { Hono } from 'hono'
import { createHonoTollBooth, type TollBoothEnv } from '@forgesworn/toll-booth/hono'
const app = new Hono<TollBoothEnv>()
app.use('/api/*', tollBooth.authMiddleware)
app.get('/api/resource', (c) => {
const balance = c.get('tollBoothCreditBalance') // remaining credits in sats
const hash = c.get('tollBoothPaymentHash') // payment hash (if just paid)
const cost = c.get('tollBoothEstimatedCost') // cost of this request in sats
const free = c.get('tollBoothFreeRemaining') // free-tier requests left
const tier = c.get('tollBoothTier') // credit tier name
const action = c.get('tollBoothAction') // 'proxy' | 'pass'
return c.json({ message: 'Paid content', balance, tier })
})Express and Web Standard adapters add these headers to the response returned to the client (caveat and tier headers are sent to the upstream only):
| Header | When | Value |
|---|---|---|
X-Credit-Balance |
After authenticated request | Remaining credit balance in sats |
X-Free-Remaining |
During free-tier request | Free-tier requests remaining today |
// Client-side: read balance from response headers
const res = await fetch('https://api.example.com/api/resource', {
headers: { Authorization: `L402 ${macaroon}:${preimage}` },
})
const balance = Number(res.headers.get('X-Credit-Balance'))
console.log(`Credits remaining: ${balance} sats`)For upstream services (behind the proxy), toll-booth adds X-Credit-Balance, X-Toll-Tier and any X-Toll-Caveat-* headers to the proxied request, so your backend can read the authenticated user's state. The Hono middleware sets the same headers on the request its downstream handlers see. Any of these headers sent by the client are removed first.
When toll-booth issues a 402 challenge, you can control both the HTTP headers and the JSON body.
Use responseHeaders to add arbitrary headers to every response, including 402 challenges:
const booth = new Booth({
adapter: 'express',
backend: phoenixdBackend({ url: 'http://localhost:9740', password: process.env.PHOENIXD_PASSWORD! }),
pricing: { '/api/resource': 10 },
upstream: 'http://localhost:8080',
responseHeaders: {
'X-Powered-By': 'toll-booth',
'Access-Control-Allow-Origin': '*',
'X-Custom-Header': 'my-value',
},
})These headers appear on 402 challenges, proxied responses, and error responses alike.
Set serviceName and description to include machine-readable service metadata in the 402 response body:
const booth = new Booth({
adapter: 'express',
backend: phoenixdBackend({ url: 'http://localhost:9740', password: process.env.PHOENIXD_PASSWORD! }),
pricing: { '/api/resource': 10 },
upstream: 'http://localhost:8080',
serviceName: 'my-api',
description: 'Premium data API with real-time market feeds',
})The 402 response body will include:
{
"message": "Payment required.",
"booth": {
"name": "my-api",
"description": "Premium data API with real-time market feeds"
},
"auth_hint": "L402: Pay the invoice, then send — Authorization: L402 <macaroon>:<preimage>",
"l402": {
"scheme": "L402",
"description": "Buy credits — pay once, reuse for multiple requests",
"invoice": "lnbc...",
"macaroon": "AgE...",
"payment_hash": "abc123...",
"amount_sats": 10,
"payment_url": "/invoice-status/abc123...?token=...",
"status_token": "..."
}
}When multiple payment rails are active (e.g. L402 + x402 + xcashu + lnurlcash), the body includes sections for each rail, and auth_hint becomes an array of instructions.
A challenge from a booth running the lnurlcash rail carries an X-LNURLcash header alongside the body section:
{
"lnurlcash": {
"amount": "10",
"currency": "sat",
"methodDetails": { "mints": ["mint.example.com"] }
},
"message": "Payment required.",
"auth_hint": "Present a LUD-25 bearer note URL in X-LNURLcash"
}The header alongside it decodes to the same charge with v and id added:
{
"v": 1,
"id": "0b86351d2cbdd44a",
"amount": "10",
"currency": "sat",
"methodDetails": { "mints": ["mint.example.com"] }
}The id is a handle on the charge, not a nonce: nothing verifies it coming
back, because a payment is identified by the note it burns. It is derived
rather than drawn at random, so the same charge always names itself the same
way.
When creditTiers are configured, the 402 body includes them so clients can offer volume discounts:
const booth = new Booth({
adapter: 'express',
backend: phoenixdBackend({ url: 'http://localhost:9740', password: process.env.PHOENIXD_PASSWORD! }),
pricing: { '/api/resource': 10 },
upstream: 'http://localhost:8080',
creditTiers: [
{ amountSats: 1000, creditSats: 1000, label: '1k sats' },
{ amountSats: 5000, creditSats: 5550, label: '5k sats (11% bonus)' },
{ amountSats: 10000, creditSats: 11100, label: '10k sats (11% bonus)' },
],
})The 402 body will include a credit_tiers array with the tier options.
| Field | Always present | Description |
|---|---|---|
message |
Yes | "Payment required." |
l402 |
When L402 rail is active | Invoice, macaroon, payment hash, amount, payment URL |
lnurlcash |
When the lnurlcash rail is active | { amount, currency, methodDetails: { mints } } - amount is a decimal string of sats, mints lists the mints whose notes are accepted |
booth |
When serviceName is set |
{ name, description } service metadata |
auth_hint |
When serviceName is set |
Human/agent-readable instructions for each active payment rail |
tiers |
For tiered-pricing routes | Available pricing tiers for the requested route |
credit_tiers |
When creditTiers is set |
Volume discount options |
The WWW-Authenticate header follows RFC 9110 and includes credentials for each active rail (e.g. L402 macaroon="...", invoice="..." and/or Payment ... and/or X-Cashu ...).
The five most common options:
| Option | Type | Description |
|---|---|---|
adapter |
'express' | 'web-standard' |
Framework integration to use. 'hono' is not a Booth adapter value: for Hono, use createHonoTollBooth() from @forgesworn/toll-booth/hono instead. |
backend |
LightningBackend |
Lightning node (optional if using Cashu-only) |
pricing |
Record<string, number | { sats?: number, usd?: number } | { default: ..., [tier]: ... }> |
Exact route path to cost. A number is sats, { sats, usd } prices in both currencies, and { default: 5, premium: 42 } is a tiered map that must include default. Matching is exact on the request path after normalisation (case-insensitive, trailing slash ignored). There is no prefix or wildcard matching: '/api' prices /api only, not /api/joke. Unlisted paths pass free unless strictPricing is on. |
upstream |
string |
URL to proxy authorised requests to |
freeTier |
{ requestsPerDay: number } or { creditsPerDay: number } |
Daily free allowance per IP (request-count or sats-budget) |
See docs/configuration.md for the full reference including rootKey, creditTiers, trustProxy, redeemCashu, and all other options. NWC is available as a merchant-owned Lightning backend, not as a client payment rail.
| Document | Description |
|---|---|
| Why L402? | The case for permissionless, machine-to-machine payments |
| Architecture | How toll-booth, satgate, and 402-mcp fit together |
| Configuration | Full reference for all Booth options |
| Deployment | Docker, nginx, Cloudflare Workers, Deno, Bun, Hono |
| Security | Threat model, macaroon security, hardening measures |
| Migration | Upgrading between major versions (v1 to v6) |
| Contributing | Development setup, conventions, adding backends |
| Project | Role |
|---|---|
| toll-booth | Payment-rail agnostic HTTP 402 middleware |
| satgate | Production showcase — pay-per-token AI inference proxy (~400 lines on toll-booth) |
| 402-announce | Publish your toll-booth services on Nostr (kind 31402) for discovery |
| 402-mcp | Client side — AI agents discover, pay, and consume L402 APIs |
If you find toll-booth useful, consider sending a tip:
- Lightning:
profusemeat89@walletofsatoshi.com - Nostr zaps:
npub1mgvlrnf5hm9yf0n5mf9nqmvarhvxkc6remu5ec3vf8r0txqkuk7su0e7q2
ForgeSworn builds open-source cryptographic identity, payments, and coordination tools for Nostr.
| Library | What it does |
|---|---|
| nsec-tree | Deterministic sub-identity derivation |
| ring-sig | SAG/LSAG ring signatures on secp256k1 |
| range-proof | Pedersen commitment range proofs |
| canary-kit | Coercion-resistant spoken verification |
| spoken-token | Human-speakable verification tokens |
| toll-booth | L402 payment middleware |
| geohash-kit | Geohash toolkit with polygon coverage |
| nostr-attestations | NIP-VA verifiable attestations |
| dominion | Epoch-based encrypted access control |
| nostr-veil | Privacy-preserving Web of Trust |
