L402 Explained: Invoices, Macaroons, Preimages
How L402 works on the wire: the 402 challenge, paying the invoice, the preimage as proof, how a server checks the macaroon, and what ln.bot does for you.
L402 is a protocol for charging per HTTP request over the Lightning Network. Lightning Labs started the specification in 2020 under the name LSAT. An unpaid request gets 402 Payment Required with a Lightning invoice and a macaroon. The client pays the invoice, which gives it the payment's preimage, and sends the request again with the macaroon and the preimage in the Authorization header. There is no account and no API key: the macaroon and the preimage together are the credential.
This page follows one request through that exchange, then covers how a server checks the token, what caveats can and can't do, and what @lnbot/l402 1.1 does at each step. How L402 compares with x402 and MPP is on x402 vs L402.
The challenge
A client asks for a paid resource without credentials:
GET /api/premium/data HTTP/1.1
Host: api.example.comThe server creates an invoice, mints a macaroon tied to it, and returns both in a WWW-Authenticate header. This is the answer from an @lnbot/l402 paywall priced at ₿10:
HTTP/1.1 402 Payment Required
WWW-Authenticate: L402 macaroon="AgJCAAA...", invoice="lnbc100n1p5..."
Content-Type: application/json; charset=utf-8
{
"type": "payment_required",
"title": "Payment Required",
"detail": "Pay the included Lightning invoice to access this resource.",
"invoice": "lnbc100n1p5...",
"macaroon": "AgJCAAA...",
"price": 10,
"unit": "satoshis",
"description": "Premium data",
"reusable": false,
"expiresAt": "2026-10-23T15:00:00.000Z"
}The spec defines only the header (section 5.1). The JSON body is @lnbot/l402's addition, and its reusable: false tells the ln.bot client not to cache the token. The invoice is an ordinary BOLT 11 invoice and carries its own amount: lnbc100n is 100 nanobitcoin, ₿10. The macaroon is base64, and its identifier contains the invoice's payment hash, which ties the two together.
One detail where implementations differ: the spec answers a credential that fails verification with 401, and an expired or used-up one with a fresh 402. @lnbot/l402 answers both with a new 402 challenge.
Paying: the preimage is the receipt
Paying a Lightning invoice reveals its preimage, 32 random bytes whose SHA-256 hash is the payment hash in the invoice. The payee picked it and kept it secret, and the HTLCs along the route release it only as the payment settles. Whoever holds the preimage has paid that invoice.
Before paying, the client reads the amount from the invoice itself. The spec requires a client to check it (section 9.5), and the invoice, not a price the server states anywhere else, is what gets paid. By hand, with the ln.bot SDK:
import { createHash } from "node:crypto";
import { l402, LnBot } from "@lnbot/l402";
const ln = new LnBot({ apiKey: process.env.LNBOT_KEY });
const w = ln.wallet("wal_...");
const url = "https://api.example.com/api/premium/data";
const first = await fetch(url);
const header = first.headers.get("www-authenticate") ?? "";
const challenge = l402.parseChallenge(header);
if (first.status !== 402 || !challenge) throw new Error("no L402 challenge");
// The invoice is what gets paid, so read the price from it: lnbc100n... is 10
const price = l402.invoiceSats(challenge.invoice);
if (price === null || price > 100) throw new Error(`won't pay ${price}`);
const paid = await w.l402.pay({ wwwAuthenticate: header });
if (paid.status !== "settled" || !paid.preimage || !paid.authorization) {
throw new Error(`payment ${paid.status}`);
}
// The receipt: sha256(preimage) is the payment hash in the invoice
const hash = createHash("sha256").update(Buffer.from(paid.preimage, "hex")).digest("hex");
console.log(hash === paid.paymentHash); // true
const second = await fetch(url, { headers: { Authorization: paid.authorization } });
console.log(second.status); // 200w.l402.pay() pays the invoice from the wallet, waits for it to settle, and returns the preimage along with a ready-made Authorization value. Between two ln.bot wallets the payment settles in under a second. Routed across the network it usually takes a few seconds, and it can fail and be retried on another route (how payments fail).
The retry
The client repeats the request with the macaroon and the preimage, joined by a colon:
GET /api/premium/data HTTP/1.1
Host: api.example.com
Authorization: L402 AgJCAAA...:9f2c...e41aThe macaroon is base64 and the preimage is 64 hex characters. A client holding several macaroons sends them comma-separated before the colon. For clients written before the rename, the spec has servers accept LSAT as the scheme name too and send both names in the challenge (section 10); @lnbot/l402 reads only L402.
The whole exchange is two HTTP requests and one Lightning payment:
client server Lightning
|-- GET /api/premium/data ------->| |
|<-- 402, macaroon + invoice -----| |
|-- pay the invoice ------------------------------------->|
|<-- preimage --------------------------------------------|
|-- GET, L402 macaroon:preimage ->| |
|<-- 200 -------------------------| |How the server checks a token
The macaroon specification defines a macaroon as an identifier, a list of caveats and a signature. In L402 the identifier is 66 bytes: a 2-byte version, the invoice's 32-byte payment hash and a 32-byte random token ID. The signature is a chain of HMAC-SHA256 operations: HMAC the identifier with a secret root key, then HMAC each caveat with the result of the step before.
Each macaroon gets its own random 32-byte root key, which the server keeps, indexed by the SHA-256 of the identifier. Deleting the key revokes the token. Checking a token takes three steps, and the first two look like this:
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
interface Macaroon {
identifier: Buffer; // version (2 bytes) | payment_hash (32) | token_id (32)
caveats: string[]; // "condition=value", in the order they were added
signature: Buffer; // 32 bytes
}
const sha256 = (data: Buffer) => createHash("sha256").update(data).digest();
const hmac = (key: Buffer, data: Buffer | string) => createHmac("sha256", key).update(data).digest();
// rootKeys holds one secret per macaroon the server has minted, keyed by sha256(identifier)
function verify(mac: Macaroon, preimageHex: string, rootKeys: Map<string, Buffer>): boolean {
// 1. Signature: look up this macaroon's root key and recompute the chain
const rootKey = rootKeys.get(sha256(mac.identifier).toString("hex"));
if (!rootKey) return false; // never minted here, or revoked
let sig = hmac(rootKey, mac.identifier);
for (const caveat of mac.caveats) sig = hmac(sig, caveat);
if (sig.length !== mac.signature.length || !timingSafeEqual(sig, mac.signature)) return false;
// 2. Payment: the preimage must hash to the payment hash inside the identifier
const paymentHash = mac.identifier.subarray(2, 34);
return sha256(Buffer.from(preimageHex, "hex")).equals(paymentHash);
}The spec calls L402 verification stateless, and the payment half is: comparing hashes needs neither the Lightning node nor a session table, and nothing records who paid. The signature half needs the root key, so an L402 server keeps one secret for every token it has issued. Lightning Labs' reference server, Aperture, stores them in a secrets table in etcd, SQLite or Postgres. What the server doesn't need is a list of customers or API keys.
The third step checks the caveats against the request: whether the token covers the service and operation asked for, and hasn't expired. The verifier evaluates each condition it knows; when a condition appears more than once, each later one must be narrower than the one before; conditions it doesn't know are skipped rather than rejected.
Caveats: narrowing a token
A caveat is a condition=value string, such as services=lightning_loop:0 or lightning_loop_capabilities=loop_out,loop_in from the spec's examples. Each link of the chain is keyed with the previous signature, so anyone holding a macaroon can add a caveat without the root key:
// Using Macaroon and hmac from above
function attenuate(mac: Macaroon, caveat: string): Macaroon {
return { ...mac, caveats: [...mac.caveats, caveat], signature: hmac(mac.signature, caveat) };
}
const loopInOnly = attenuate(mac, "lightning_loop_capabilities=loop_in");Removing a caveat would mean recovering an earlier signature from a later one, which HMAC doesn't allow. A token can be handed on with less authority, to a sub-agent for example, but never with more.
Two limits come from the spec itself. A macaroon doesn't prevent replay on its own: that takes expiry caveats, usage counting or revocation (section 9.4). And it is a bearer token, usable by whoever copies it, which is why the spec requires TLS and suggests caveats that bind a token to one client, such as its IP address (section 9.3).
The ln.bot paywall
With @lnbot/l402, ln.bot's API mints the macaroons, keeps the root keys and checks signature, preimage and expiry. The Express middleware checks the rest:
import express from "express";
import { l402, LnBot } from "@lnbot/l402";
const app = express();
const ln = new LnBot({ apiKey: process.env.LNBOT_KEY });
// ₿10 a request, on every route under /api/premium
app.use("/api/premium", l402.paywall(ln, { walletId: "wal_...", price: 10, description: "Premium data" }));
app.get("/api/premium/data", (req, res) => {
// req.l402 is set once the token has been verified and spent
res.json({ data: "...", paid: req.l402?.price, paymentHash: req.l402?.paymentHash });
});
app.listen(3000);In 1.1 a token pays for one request by default. Each challenge carries two caveats, lnbot_path=/api/premium/data and lnbot_price=10. The token opens that path (the query string doesn't count) at that price or a lower one, and because caveats can't be removed, a holder can't stretch it to another route. Tokens expire after an hour unless you set expirySeconds. When a token is used, the paywall remembers its payment hash until it expires, and a second use gets a new 402. A token counts as spent once the request reaches your handler, even if the handler then fails.
The spec doesn't require this. It expects a client to reuse a credential until the server rejects it (section 8); @lnbot/l402 defaults to one request per token because it prices requests one at a time. For the spec's model, a pass, set reuse: true:
app.use("/api/pass", l402.paywall(ln, {
walletId: "wal_...",
price: 500,
reuse: true, // the token keeps working, on any route under /api/pass
expirySeconds: 3600, // until it expires; without this it never does
}));A pass is bound too, to the paywall rather than one path: its challenge carries lnbot_pass=/api/pass and lnbot_price=500, so it opens every route under /api/pass until it expires, at ₿500 or less. A single-use token bought for ₿10 on another route doesn't open the pass, and a pass doesn't open single-use routes.
Spent tokens are remembered in the process's memory. If the API runs on more than one instance, a token spent on one could be spent again on another, so give every instance the same spentStore. With Redis, as in the library's README:
import { createClient } from "redis";
import type { SpentStore } from "@lnbot/l402";
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const spentStore: SpentStore = {
// SET NX EX: only the first instance to claim a token gets "OK"
async claim(paymentHash, ttlSeconds) {
return (await redis.set(`l402:spent:${paymentHash}`, "1", { NX: true, EX: ttlSeconds })) === "OK";
},
};
app.use("/api/premium", l402.paywall(ln, { walletId: "wal_...", price: 10, spentStore }));The ASP.NET Core package, LnBot.L402.AspNetCore, has the same defaults: app.UseL402Paywall("/api/premium", new L402Options { WalletId = "wal_...", Price = 10 }), with Reuse, ExpirySeconds and an ISpentTokenStore registered in DI. The pay-per-API-call guide builds both servers end to end.
The ln.bot client
import { l402, LnBot, L402BudgetExceededError } from "@lnbot/l402";
const ln = new LnBot({ apiKey: process.env.LNBOT_KEY });
const api = l402.client(ln, {
walletId: "wal_...",
maxPrice: 100, // refuse any invoice above ₿100
budgetSats: 50_000, // and anything past ₿50,000 a day
budgetPeriod: "day",
});
try {
const data = await api.get("https://api.example.com/api/premium/data");
console.log(data);
} catch (err) {
if (!(err instanceof L402BudgetExceededError)) throw err;
// over maxPrice or over budget: nothing was paid
}On a 402 the client parses the challenge and reads the amount from the invoice with l402.invoiceSats(). It refuses an invoice with no amount, one above maxPrice (₿1,000 if you don't set it), and one that would take the period's spending past budgetSats. Otherwise it pays with w.l402.pay() and retries. It caches a token unless the server marks it single-use, as the 1.1 paywall does, so ten calls to a single-use paywall are ten payments.
The budget is a counter in the process's memory. It starts from zero when the process restarts, and it doesn't see payments made any other way. An agent on ln.bot's MCP server pays challenges with the pay_l402_challenge tool, which has no cap of its own. The limit that always holds is the wallet balance, so fund an agent's wallet with only what it may spend (spending discipline). The agent payments guide sets up both the SDK and MCP routes.
What ln.bot's verify costs
Each request through the paywall makes an HTTPS call to api.ln.bot: verify when it carries a token, createChallenge when it doesn't, and a refused token costs both. A paid request waits for that round trip before your handler runs. If api.ln.bot can't be reached, the paywall can neither check tokens nor issue challenges, and it passes the error to your Express error handler.
In exchange, the root keys, the Lightning node and the channel liquidity are ln.bot's to run. The only state your server keeps is the set of spent tokens. Self-hosting with Aperture makes the check local, and makes lnd, its channels and its database yours to operate.
There is no L402 middleware for Python, Go or Rust. Their SDKs have the same two calls, and the server does the binding and the bookkeeping itself. In Python:
from lnbot import BadRequestError, LnBot
ln = LnBot(api_key="uk_...")
w = ln.wallet("wal_...")
spent: set[str] = set() # share this between processes if you run more than one
def challenge(path: str, price: int) -> str:
"""No token: mint an invoice and a macaroon bound to this route and price."""
ch = w.l402.create_challenge(
amount=price,
expiry_seconds=3600,
caveats=[f"lnbot_path={path}", f"lnbot_price={price}"],
)
return ch.www_authenticate # send it with a 402
def accept(authorization: str, path: str, price: int) -> bool:
"""A token: ln.bot checks signature, preimage and expiry; the binding is yours."""
try:
v = w.l402.verify(authorization=authorization)
except BadRequestError:
return False
caveats = v.caveats or []
paths = [c.removeprefix("lnbot_path=") for c in caveats if c.startswith("lnbot_path=")]
prices = [c.removeprefix("lnbot_price=") for c in caveats if c.startswith("lnbot_price=")]
if not (v.valid and v.payment_hash and paths and prices):
return False
if any(p != path for p in paths) or any(not p.isdigit() or int(p) < price for p in prices):
return False
if v.payment_hash in spent:
return False
spent.add(v.payment_hash)
return TrueThe payment has its own cost. Between two ln.bot wallets it is free and settles in under a second. Paying a server outside ln.bot costs 0.25% with a ₿1 minimum, plus the network fee (another 0.25% and routing), so an ln.bot client pays ₿11 plus the network fee for a ₿10 request to someone else's node. Receiving is free, so a seller on ln.bot keeps the whole ₿10.
FAQ
What is L402?
A protocol from Lightning Labs for charging per HTTP request over Lightning. The server answers an unpaid request with 402 Payment Required, a Lightning invoice and a macaroon. The client pays the invoice, which gives it the payment's preimage, and retries with Authorization: L402 <macaroon>:<preimage>. The server checks that it minted the macaroon and that the preimage hashes to the payment hash inside it.
Is L402 the same as LSAT?
Yes. LSAT is the protocol's earlier name. The specification asks servers to send both scheme names in WWW-Authenticate and to accept both in Authorization, so older clients keep working. The @lnbot/l402 middleware and client read only L402.
Does an L402 server need a database?
For the payment check, no: comparing sha256(preimage) with the payment hash in the macaroon needs no lookup. For the signature check, yes: the macaroon specification gives every macaroon its own random root key, which the server stores and looks up by the hash of the macaroon's identifier. With ln.bot, ln.bot keeps those keys and the check is one HTTPS call.
Can an L402 token be used more than once?
The specification expects clients to reuse a token until the server stops accepting it. @lnbot/l402 1.1 defaults to one request per token, bound to the route and price it was issued for, because it prices each request. With reuse: true a token is a pass: it works on any route under that paywall, at its price or less, until it expires.
How does an L402 client avoid paying too much?
By reading the amount from the invoice before paying, since the invoice is what gets paid. The @lnbot/l402 client does this and refuses invoices above maxPrice or past its budget. The budget is kept in memory and resets when the process restarts, and the MCP tools have no spending cap of their own, so fund an agent's wallet with only what it may spend.