Native L402 Integration — Express (Node.js)
This walkthrough takes you from a vanilla Express app to charging Lightning payments per request in under 10 minutes. You'll install l402-express, add one line of middleware, and your existing endpoints become paid endpoints.
If you haven't picked an integration mode yet, start with the Native Integration overview to understand when Native mode is the right fit.
A complete, working Express integration lives at l402-example-node — clone it, set your API key, and curl a live 402 → pay → 200 flow before wiring your own app.
Prerequisites
- Node.js 18 or higher
- An Express app (4.x or 5.x)
- A Lightning Enable merchant API key — generate at Dashboard → Settings → API Keys
- A payment provider (Strike or OpenNode) configured under Dashboard → Settings → Payment Provider
Install
npm install l402-express l402-server
l402-server is the underlying SDK that l402-express calls; both are MIT-licensed and the middleware re-exports the SDK's types so most consumers won't import l402-server directly.
30-second example
import express from "express";
import { l402 } from "l402-express";
const app = express();
// Anything mounted here costs 100 sats per request.
app.use("/api/premium", l402({
apiKey: process.env.LIGHTNING_ENABLE_API_KEY,
priceSats: 100,
}));
app.get("/api/premium/weather", (_req, res) => {
res.json({ temp: 72 });
});
app.listen(3000);
That's the whole integration. The middleware:
- Reads
Authorization: L402 <macaroon>:<preimage>from each request - If absent → mints a fresh challenge via Lightning Enable's hosted producer API and returns
402 Payment Requiredwith the invoice in theWWW-Authenticateheader and a JSON body - If present → verifies the credential via Lightning Enable. On valid → call
next(); on invalid → respond401 Unauthorized
Endpoints NOT mounted under l402(...) pass through untouched. So you can mix paid and free routes freely.
Current versions (l402-express 0.1.x, l402-server 0.1.x) call the verify endpoint with only the macaroon and preimage — they do not pass or compare the request path or amount. Merchant binding and expiry are always enforced by the hosted API, but a token bought for one of your routes will also pass verification on a different route gated under the same API key, within the token's validity window (60 minutes by default).
If you gate multiple prices or resources (e.g., /api/cheap at 10 sats and /api/premium at 500 sats), add a resource assertion in your handler by comparing res.locals.l402.resource against the current request's path:
router.get("/forecast", l402({ apiKey: KEY, priceSats: 100 }), (req, res) => {
if (res.locals.l402.resource !== req.path) {
return res.status(401).json({ error: "Token was purchased for a different resource" });
}
res.json({ forecast: "7-day data..." });
});
One Express subtlety: the default bound resource is req.path as the middleware sees it. In per-route usage (above) that's the same value your handler sees, so the comparison is direct. But when you mount with app.use("/api/premium", l402(...)), Express strips the mount prefix inside the middleware, so the token gets bound to the mount-relative path (/weather, not /api/premium/weather). In that pattern, bind the full path explicitly and compare against the same expression:
app.use("/api/premium", l402({
apiKey: KEY,
priceSats: 500,
resource: (req) => req.baseUrl + req.path, // bind the full path
}));
app.get("/api/premium/weather", (req, res) => {
if (res.locals.l402.resource !== req.baseUrl + req.path) {
return res.status(401).json({ error: "Token was purchased for a different resource" });
}
res.json({ temp: 72 });
});
An SDK update that passes the request path through to the verify call automatically is in progress; this applies to the 0.1.x releases. A single mount with a single flat price is unaffected in practice — every token you issue is for the same class of access.
What the caller sees
Without payment
curl -i https://your-api.example/api/premium/weather
HTTP/1.1 402 Payment Required
Content-Type: application/json
WWW-Authenticate: L402 macaroon="AgELbWFjYXJvb24...", invoice="lnbc1u1p3..."
{
"error": "Payment Required",
"l402": {
"macaroon": "AgELbWFjYXJvb24...",
"invoice": "lnbc1u1p3...",
"amount_sats": 100,
"payment_hash": "abc123...",
"expires_at": "2026-05-12T01:00:00Z",
"resource": "/api/premium/weather"
}
}
With payment
After paying the Lightning invoice and extracting the preimage:
curl -i https://your-api.example/api/premium/weather \
-H 'Authorization: L402 AgELbWFjYXJvb24...:deadbeef...'
HTTP/1.1 200 OK
Content-Type: application/json
{ "temp": 72 }
Variable per-request pricing
Pass a function instead of a static priceSats to derive the price from the request:
app.use("/api/llm", l402({
apiKey: process.env.LIGHTNING_ENABLE_API_KEY,
priceSats: (req) => req.query.model === "premium" ? 500 : 100,
}));
The function can return a number or a Promise<number>. Use it for tiered pricing, user-based pricing, dynamic cost-of-goods scenarios, etc.
resource and description accept the same function-or-static shape.
Configuration reference
| Option | Type | Default | Notes |
|---|---|---|---|
apiKey | string | required (one of apiKey / client) | Merchant API key |
client | L402Server | — | Pre-constructed SDK client; use to share across mounts |
priceSats | number | (req) => number | Promise<number> | required | Price in satoshis, ≥ 1 |
resource | string | (req) => string | Promise<string> | req.path | Bound as a macaroon caveat |
description | string | (req) => string | undefined | none | Shown in the payer's Lightning wallet |
idempotencyKey | (req) => string | undefined | client IP | Sends X-Idempotency-Key for retry-safe challenge issuance |
baseUrl | string | https://api.lightningenable.com | Override producer API URL (testing) |
onInvalidToken | (req, res, failure, next) => void | Promise<void> | sends 401 | Custom failure handler — useful for sending a fresh 402 instead |
Accessing the verified credential in your handler
After a successful verification the middleware sets res.locals.l402 so downstream handlers can see what was paid for:
app.get("/api/premium/weather", (_req, res) => {
const { resource, amountSats, paymentHash } = res.locals.l402;
console.log(`Served ${resource} for ${amountSats} sats (${paymentHash})`);
res.json({ temp: 72 });
});
Useful for usage logging, per-endpoint analytics, fraud detection.
Mounting patterns
One price for everything
app.use(l402({ apiKey: KEY, priceSats: 50 }));
// every endpoint below this costs 50 sats
Different prices for different sub-paths
app.use("/api/cheap", l402({ apiKey: KEY, priceSats: 10 }));
app.use("/api/premium", l402({ apiKey: KEY, priceSats: 500 }));
Per-route in a Router
import express from "express";
import { l402 } from "l402-express";
const router = express.Router();
router.get("/forecast", l402({ apiKey: KEY, priceSats: 100 }), forecastHandler);
router.get("/historical", l402({ apiKey: KEY, priceSats: 50 }), historyHandler);
router.get("/free-info", freeInfoHandler); // not gated
app.use("/api", router);
Sharing one SDK client across mounts
Each l402(...) call constructs its own internal L402Server instance. For high-throughput apps you may prefer one shared instance with shared HTTP connection pooling:
import { L402Server } from "l402-server";
import { l402 } from "l402-express";
const client = new L402Server({ apiKey: process.env.LIGHTNING_ENABLE_API_KEY });
app.use("/api/cheap", l402({ client, priceSats: 10 }));
app.use("/api/premium", l402({ client, priceSats: 500 }));
Mutually exclusive with apiKey — pass one or the other.
Custom failure handling
By default an invalid L402 token returns 401 Unauthorized. Some merchants prefer to issue a fresh 402 so the caller can retry without having to make a separate request first:
app.use(l402({
apiKey: KEY,
priceSats: 100,
onInvalidToken: async (_req, res, failure, _next) => {
// Send a fresh 402 challenge instead of 401.
// (You'd typically mint a new challenge via the SDK here. Sketch only —
// see the runnable app at
// https://github.com/refined-element/l402-example-node for a complete
// integration.)
res.status(402).json({ error: "Token rejected; please pay again", details: failure.error });
},
}));
When onInvalidToken is supplied, the middleware does NOT send the default 401 — you are responsible for either sending a response or calling next().
Troubleshooting
"Bad Gateway" 502 on every request
The middleware couldn't reach Lightning Enable. Check:
LIGHTNING_ENABLE_API_KEYis set and valid- No outbound firewall blocking
api.lightningenable.com - Subscription is active
Every request returns 402 even after payment
The Authorization header probably isn't reaching your Express app. If you're behind a reverse proxy (nginx, Cloudflare, load balancer), confirm the Authorization header is being forwarded.
403 Forbidden from upstream
L402 isn't enabled on your subscription plan. Check Dashboard → Settings → Plan — Native mode requires Agentic Commerce — Individual or Business.
Source and license
Both packages are MIT-licensed open source:
Issues, pull requests, and protocol-level discussion at lightninglabs/L402 all welcome.
Next steps
- Producer API reference — full HTTP surface if you ever want to call the underlying API directly
- Native Integration — ASP.NET Core — same flow for .NET
- Proxy setup walkthrough — if you decide Proxy mode is a better fit