L402 payment middleware for Express.js — paywall any API in one line. Built on ln.bot.
Add Lightning-powered pay-per-request to any Express API. Protect premium routes with a paywall, or build clients that auto-pay L402-protected services — all without touching any cryptography.
import express from "express";
import { l402, LnBot } from "@lnbot/l402";
const app = express();
const ln = new LnBot({ apiKey: "key_..." });
app.use("/api/premium", l402.paywall(ln, { price: 10 }));
app.get("/api/premium/data", (req, res) => {
res.json({ data: "premium content" });
});This package is a thin glue layer. All L402 logic — macaroon creation, signature verification, preimage checking — lives in the ln.bot API via
@lnbot/sdk. Zero crypto dependencies.
L402 is a protocol built on HTTP 402 Payment Required. It enables machine-to-machine micropayments over the Lightning Network:
- Client requests a protected resource
- Server returns
402with a Lightning invoice and a macaroon token - Client pays the invoice, obtains the preimage as proof of payment
- Client retries the request with
Authorization: L402 <macaroon>:<preimage> - Server verifies the token and grants access
L402 is ideal for API monetization, AI agent tool access, pay-per-request data feeds, and any scenario where you want instant, permissionless, per-request payments without subscriptions or API key provisioning.
npm install @lnbot/l402pnpm add @lnbot/l402yarn add @lnbot/l402@lnbot/sdk and express are peer dependencies and will be resolved automatically.
The l402.paywall() middleware intercepts requests, verifies L402 tokens via the SDK, and issues new challenges when payment is needed. Two SDK calls, ~40 lines of glue code, zero crypto.
import express from "express";
import { l402, LnBot } from "@lnbot/l402";
const app = express();
const ln = new LnBot({ apiKey: "key_..." });
// Paywall a route group — 10 sats per request
app.use("/api/premium", l402.paywall(ln, {
price: 10,
description: "API access",
}));
app.get("/api/premium/data", (req, res) => {
// req.l402 is populated after successful payment verification
res.json({
data: "premium content",
paymentHash: req.l402?.paymentHash,
});
});
// Free routes still work normally
app.get("/api/free/health", (req, res) => {
res.json({ status: "ok" });
});
app.listen(3000);- Checks for an
Authorization: L402 ...header - If present, calls
ln.l402.verify()— the SDK checks signature, preimage, and caveats server-side - If valid, populates
req.l402and callsnext() - If missing or invalid, calls
ln.l402.createChallenge()and returns a402response with the invoice and macaroon
// Fixed price per route
app.use("/api/cheap", l402.paywall(ln, { price: 1 }));
app.use("/api/expensive", l402.paywall(ln, { price: 100 }));
// Custom pricing function — receives the request, returns price in sats
app.use("/api/dynamic", l402.paywall(ln, {
price: (req) => {
if (req.path.includes("/bulk")) return 50;
return 5;
},
}));| Option | Type | Description |
|---|---|---|
price |
number | (req) => number |
Price in satoshis — fixed or per-request |
description |
string |
Invoice memo shown in wallets |
expirySeconds |
number |
Challenge expiry in seconds |
caveats |
string[] |
Macaroon caveats to attach |
The l402.client() wrapper makes L402 payment transparent. It detects 402 responses, pays the Lightning invoice via the SDK, caches the token, and retries — all in one fetch call.
import { l402, LnBot } from "@lnbot/l402";
const ln = new LnBot({ apiKey: "key_..." });
const client = l402.client(ln, {
maxPrice: 100, // refuse to pay more than 100 sats per request
budgetSats: 50000, // spending limit for the period
budgetPeriod: "day", // reset period: "hour" | "day" | "week" | "month"
store: "memory", // token cache: "memory" (default) | "none" | custom TokenStore
});
// Use like fetch — L402 payment is transparent
const response = await client.fetch("https://api.example.com/premium/data");
const data = await response.json();
// Convenience methods — all HTTP verbs
const json = await client.get("https://api.example.com/premium/data");
const result = await client.post("https://api.example.com/premium/submit", {
body: JSON.stringify({ query: "test" }),
});
await client.put("https://api.example.com/premium/item/1", {
body: JSON.stringify({ name: "updated" }),
});
await client.patch("https://api.example.com/premium/item/1", {
body: JSON.stringify({ status: "active" }),
});
await client.delete("https://api.example.com/premium/item/1");- Checks the token cache for a valid credential
- If cached, sends the request with the
Authorizationheader - If no cache (or server rejects), makes a plain request
- On
402, parses the challenge and checks budget limits - Calls
ln.l402.pay()— the SDK pays the invoice and returns a ready-to-use token - Caches the token and retries the request with authorization
| Option | Type | Default | Description |
|---|---|---|---|
maxPrice |
number |
1000 |
Max sats to pay for a single request |
budgetSats |
number |
unlimited | Total budget in sats for the period |
budgetPeriod |
string |
— | Reset period: "hour", "day", "week", "month" |
store |
string | TokenStore |
"memory" |
Token cache: "memory", "none", or custom |
Implement the TokenStore interface for Redis, file system, or any persistence layer:
import { l402, LnBot } from "@lnbot/l402";
import type { TokenStore } from "@lnbot/l402";
const ln = new LnBot({ apiKey: "key_..." });
const redisStore: TokenStore = {
async get(url) { /* read from Redis */ },
async set(url, token) { /* write to Redis */ },
async delete(url) { /* delete from Redis */ },
};
const client = l402.client(ln, { store: redisStore });Parse and format L402 headers for custom integrations:
import { l402 } from "@lnbot/l402";
// Parse Authorization: L402 <macaroon>:<preimage>
l402.parseAuthorization("L402 mac_base64:preimage_hex");
// → { macaroon: "mac_base64", preimage: "preimage_hex" }
// Parse WWW-Authenticate: L402 macaroon="...", invoice="..."
l402.parseChallenge('L402 macaroon="abc", invoice="lnbc1..."');
// → { macaroon: "abc", invoice: "lnbc1..." }
// Format headers
l402.formatAuthorization("mac_base64", "preimage_hex");
// → "L402 mac_base64:preimage_hex"
l402.formatChallenge("abc", "lnbc1...");
// → 'L402 macaroon="abc", invoice="lnbc1..."'import { L402Error, L402BudgetExceededError, L402PaymentFailedError } from "@lnbot/l402";
try {
const data = await client.get("https://api.example.com/expensive");
} catch (err) {
if (err instanceof L402BudgetExceededError) {
// Price exceeds maxPrice or total budget exhausted
} else if (err instanceof L402PaymentFailedError) {
// Lightning payment failed or didn't settle
} else if (err instanceof L402Error) {
// Other L402 protocol error (missing header, parse failure)
}
}| Export | Description |
|---|---|
l402.paywall(ln, options) |
Express middleware factory — protects routes behind an L402 paywall |
| Export | Description |
|---|---|
l402.client(ln, options?) |
Creates an L402-aware HTTP client with automatic payment |
client.fetch(url, init?) |
L402-aware fetch — pays 402 challenges automatically |
client.get(url, init?) |
GET + JSON parse with automatic L402 payment |
client.post(url, init?) |
POST + JSON parse with automatic L402 payment |
client.put(url, init?) |
PUT + JSON parse with automatic L402 payment |
client.patch(url, init?) |
PATCH + JSON parse with automatic L402 payment |
client.delete(url, init?) |
DELETE + JSON parse with automatic L402 payment |
| Export | Description |
|---|---|
l402.parseAuthorization(header) |
Parse Authorization: L402 ... into { macaroon, preimage } |
l402.parseChallenge(header) |
Parse WWW-Authenticate: L402 ... into { macaroon, invoice } |
l402.formatAuthorization(macaroon, preimage) |
Format an Authorization header value |
l402.formatChallenge(macaroon, invoice) |
Format a WWW-Authenticate header value |
| Type | Description |
|---|---|
L402PaywallOptions |
Options for l402.paywall() |
L402ClientOptions |
Options for l402.client() |
L402Token |
Cached L402 credential (macaroon + preimage + metadata) |
TokenStore |
Interface for custom token caches |
L402RequestData |
Data attached to req.l402 after verification |
| Class | Description |
|---|---|
L402Error |
Base error for all L402 protocol errors |
L402BudgetExceededError |
Price or cumulative spend exceeds configured limits |
L402PaymentFailedError |
Lightning payment failed or didn't return authorization |
- Node.js 18+, Bun, or Deno
- Express 4+ (server middleware)
- An ln.bot API key — create a wallet to get one
@lnbot/sdk— The TypeScript SDK this package is built on- Python SDK · pypi
- Go SDK · pkg.go.dev
- Rust SDK · crates.io
- ln.bot — website
- Documentation
- L402 specification
- GitHub
- npm
MIT