Skip to main content

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.

Runnable example

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:

  1. Reads Authorization: L402 <macaroon>:<preimage> from each request
  2. If absent → mints a fresh challenge via Lightning Enable's hosted producer API and returns 402 Payment Required with the invoice in the WWW-Authenticate header and a JSON body
  3. If present → verifies the credential via Lightning Enable. On valid → call next(); on invalid → respond 401 Unauthorized

Endpoints NOT mounted under l402(...) pass through untouched. So you can mix paid and free routes freely.

l402-express 0.1.x verifies macaroon + preimage only — assert the resource yourself when gating multiple tiers

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

OptionTypeDefaultNotes
apiKeystringrequired (one of apiKey / client)Merchant API key
clientL402ServerPre-constructed SDK client; use to share across mounts
priceSatsnumber | (req) => number | Promise<number>requiredPrice in satoshis, ≥ 1
resourcestring | (req) => string | Promise<string>req.pathBound as a macaroon caveat
descriptionstring | (req) => string | undefinednoneShown in the payer's Lightning wallet
idempotencyKey(req) => string | undefinedclient IPSends X-Idempotency-Key for retry-safe challenge issuance
baseUrlstringhttps://api.lightningenable.comOverride producer API URL (testing)
onInvalidToken(req, res, failure, next) => void | Promise<void>sends 401Custom 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_KEY is 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