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.
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.
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 — full example in the GitHub repo.)
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