Skip to main content

Installation

npm install l402-kit
Requirements: Node.js 18+, Express 4+

Quickstart

import express from 'express';
import { l402, BlinkProvider } from 'l402-kit';

const app = express();

// Payments go directly to your Blink wallet — 0% fee
const lightning = new BlinkProvider(
  process.env.BLINK_API_KEY!,
  process.env.BLINK_WALLET_ID!,
);

app.get('/api/data', l402({ priceSats: 10, lightning }), (req, res) => {
  res.json({ data: 'premium content' });
});

app.listen(3000);

l402(options) — middleware

Returns an Express RequestHandler that enforces L402 payment on the route.

Options

OptionTypeDefaultDescription
priceSatsnumberrequiredPrice per call in satoshis
lightningLightningProviderrequiredYour Lightning backend
supabaseUrlstringSUPABASE_URL envSupabase URL for payment logging
supabaseKeystringSUPABASE_ANON_KEY envSupabase key for payment logging
onPayment(token, amountSats) => voidCallback fired after each verified payment
webhookUrlstringYour endpoint to receive signed payment events
webhookSecretstringHMAC-SHA256 secret for webhook signing
replayAdapterReplayAdapterin-memoryPluggable replay protection backend

Behavior

RequestResponse
No Authorization header402 Payment Required with BOLT11 invoice + macaroon
Valid L402 <macaroon>:<preimage>next() — handler executes
Invalid or expired token401 Unauthorized
Replayed preimage401 Token already used

402 response body

{
  "error": "Payment Required",
  "invoice": "lnbc100n1p...",
  "macaroon": "eyJoYXNoIjoiYWJjMTIzIiwiZXhwIjoxNzAwMDAwMDAwfQ==",
  "priceSats": 10
}

WWW-Authenticate header

WWW-Authenticate: L402 macaroon="eyJ...", invoice="lnbc..."

Providers

Alby — self-custodial wallet. You control the keys.
import { AlbyProvider } from 'l402-kit';

// 1. Create an Alby Hub at hub.getalby.com (or self-host)
// 2. Settings → Access Tokens → create token (scopes: invoices:create, invoices:read)
const lightning = new AlbyProvider(
  process.env.ALBY_ACCESS_TOKEN!, // Hub access token
  process.env.ALBY_HUB_URL!,      // e.g. "https://your-name.getalby.com"
);

BTCPayProvider — self-hosted, zero trust

Run your own BTCPay Server. Full soberanoty.
import { BTCPayProvider } from 'l402-kit';

const lightning = new BTCPayProvider(
  process.env.BTCPAY_URL!,         // https://your-btcpay.com
  process.env.BTCPAY_API_KEY!,     // store API key
  process.env.BTCPAY_STORE_ID!,    // store ID
);

BlinkProvider — custodial, easiest start

Blink — free, no KYC for small amounts.
import { BlinkProvider } from 'l402-kit';

const lightning = new BlinkProvider(
  process.env.BLINK_API_KEY!,    // dashboard.blink.sv → API Keys
  process.env.BLINK_WALLET_ID!,  // your BTC wallet ID
);

LNbitsProvider

Self-hosted or legend.lnbits.com.
import { LNbitsProvider } from 'l402-kit';

const lightning = new LNbitsProvider(
  process.env.LNBITS_API_KEY!,
  'https://your-lnbits-instance.com', // optional, defaults to legend.lnbits.com
);

OpenNodeProvider

import { OpenNodeProvider } from 'l402-kit';

const lightning = new OpenNodeProvider(
  process.env.OPENNODE_API_KEY!,
  false, // testMode — set to true for sandbox
);

ManagedProvider — cloud mode (0.3% fee)

l402kit.com hosts the Lightning node. You receive 99.7% of each payment. Explicit opt-in.
import { ManagedProvider } from 'l402-kit';

const lightning = ManagedProvider.fromAddress('you@yourdomain.com');

// Optional: auto-register in the public API directory
const lightning = ManagedProvider.fromAddress('you@yourdomain.com', {
  registerDirectory: {
    url: 'https://api.you.com/v1/data',
    name: 'My Data API',
    priceSats: 10,
    category: 'data',          // data | ai | finance | weather | compute | storage | other
    description: 'Optional',
  },
});
Registration fires once at startup (fire-and-forget, errors are silent). The API appears at l402kit.com/apis.json so agents can discover it automatically.

Replay protection

Default — in-memory (development)

Built-in. Resets on restart. Suitable for single-process deployments.
app.get('/api', l402({ priceSats: 10, lightning }), handler);

Redis (production — multi-instance)

import Redis from 'ioredis';
import { l402, RedisReplayAdapter } from 'l402-kit';

const replay = new RedisReplayAdapter(
  new Redis(process.env.REDIS_URL!),
  86400, // TTL in seconds (24h)
);

app.get('/api', l402({ priceSats: 10, lightning, replayAdapter: replay }), handler);
RedisReplayAdapter uses SET key 1 NX EX ttl — atomic, race-condition free.

Payment webhooks

Receive a signed event after each payment.
import { l402, verifyWebhook } from 'l402-kit';

app.get('/api/data', l402({
  priceSats: 10,
  lightning,
  webhookUrl: 'https://yourapi.com/webhooks/l402',
  webhookSecret: process.env.L402_WEBHOOK_SECRET!,
}), handler);

// Webhook receiver
app.post('/webhooks/l402', express.raw({ type: '*/*' }), (req, res) => {
  const valid = verifyWebhook(
    process.env.L402_WEBHOOK_SECRET!,
    req.body.toString(),
    req.headers['l402-signature'] as string,
  );
  if (!valid) return res.status(401).end();

  const event = JSON.parse(req.body.toString());
  console.log('Payment:', event.data.paymentHash, event.data.amountSats);
  res.json({ ok: true });
});
Webhook payload:
{
  "id": "evt_abc123",
  "type": "payment.received",
  "created": 1700000000,
  "data": {
    "endpoint": "/api/data",
    "amountSats": 10,
    "paymentHash": "sha256-of-preimage"
  }
}

onPayment callback

Synchronous hook called after each verified payment, before next():
app.get('/api/data', l402({
  priceSats: 10,
  lightning,
  onPayment: async ({ macaroon, preimage }, amountSats) => {
    await myAnalytics.track('payment', { amountSats });
  },
}), handler);

Supabase payment logging

Set SUPABASE_URL + SUPABASE_ANON_KEY in your environment to log payments automatically.
app.get('/api', l402({ priceSats: 10, lightning }), handler);
// SUPABASE_URL and SUPABASE_ANON_KEY are read from process.env automatically
Payments table schema (payments):
create table payments (
  id            uuid primary key default gen_random_uuid(),
  payment_hash  text unique not null,  -- SHA256(preimage) — safe to store
  endpoint      text,
  amount_sats   integer,
  paid_at       timestamptz default now()
);
payment_hash stores SHA256(preimage), not the raw preimage. The preimage is the 32-byte Lightning payment secret — its hash is already public in the BOLT11 invoice.

Standalone utilities

import { verifyToken, parseToken, checkAndMarkPreimage } from 'l402-kit';

// Verify a token (returns true/false)
const isValid = await verifyToken('eyJoYXNoIjoi...:deadbeef...');

// Parse token parts
const { macaroon, preimage } = parseToken(token);

// Custom replay check (returns true = first use, false = replay)
const isFirstUse = await checkAndMarkPreimage(preimage);

Types

import type { L402Options, LightningProvider, Invoice, L402Token } from 'l402-kit';

interface L402Options {
  priceSats: number;
  lightning: LightningProvider;
  supabaseUrl?: string;
  supabaseKey?: string;
  onPayment?: (token: L402Token, amountSats: number) => void | Promise<void>;
  webhookUrl?: string;
  webhookSecret?: string;
  replayAdapter?: ReplayAdapter;
}

interface Invoice {
  paymentRequest: string;
  paymentHash: string;
  macaroon: string;
  amountSats: number;
  expiresAt: number;        // Unix ms
}

interface LightningProvider {
  createInvoice(amountSats: number): Promise<Invoice>;
  checkPayment(paymentHash: string): Promise<boolean>;
}

Verification timing

Token verification runs SHA256(preimage) == paymentHash in memory — sub-millisecond, no network call on the hot path. The in-memory ReplayAdapter (default) also runs synchronously. If you use RedisReplayAdapter, add a 5–50 ms Redis round-trip per request. Plan capacity accordingly for high-frequency endpoints.

x402 compatibility (X-Payment header)

The middleware silently accepts the X-Payment header (used by Coinbase’s x402 protocol) in addition to the standard Authorization: L402 … header. Both are treated identically — useful if you want to serve clients that speak either protocol.
// Client using x402 header — handled transparently
// X-Payment: <macaroon>:<preimage>
No configuration needed; it is always enabled.

Migration guide

v1.1 → v1.2

Rename column in your payments table:
ALTER TABLE payments RENAME COLUMN preimage TO payment_hash;