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.

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.

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 — 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_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