Skip to main content

Pre-launch checklist

Run through this before going live. Each item links to the relevant section below.
1

Replay protection configured for multi-instance

Default adapter is in-memory only. If you run more than one process (Gunicorn workers, PM2 cluster, Kubernetes), set SUPABASE_URL + SUPABASE_ANON_KEY or use RedisReplayAdapter. Details →
2

Environment variables in secrets manager

Never hardcode API keys. Use .env locally, secrets manager in prod. Details →
3

Health check endpoint exists

Monitoring pings must not trigger invoice creation. Add a /health route before your paid routes. Details →
4

Error responses don't leak internals

Catch provider errors and return a clean 503 — not a stack trace. Details →
5

Price is intentional

priceSats should reflect real value. At 1 sat ≈ 0.0006,100sats0.0006, 100 sats ≈ 0.06 for a premium endpoint is reasonable. Don’t set it to 0 by accident.
6

Uptime monitoring on the invoice endpoint

Monitor the 402 response (it’s a normal response, not an error). Tools like UptimeRobot support custom status code expectations.
7

Rate limiting on invoice creation

Every unauthenticated request creates a Lightning invoice. Without rate limiting, an attacker can exhaust your provider’s invoice quota for free. Add express-rate-limit before deploying publicly. Details →

Critical: replay protection in production

The default replay adapter is in-memory only — it resets on every process restart and does not work across multiple server instances. In production with more than one process (Gunicorn workers, Kubernetes pods, PM2 cluster), the same preimage can be accepted twice.Fix: Set SUPABASE_URL + SUPABASE_ANON_KEY in your environment. The middleware will automatically use Supabase as the replay store, which is shared across all instances.For Redis: pass a RedisReplayAdapter explicitly (see TypeScript SDK or Python SDK).

Environment variables

Never hardcode keys. Always use environment variables:
# .env (never commit this)
BLINK_API_KEY=blink_xxx
BLINK_WALLET_ID=your-wallet-id
SUPABASE_URL=https://xxx.supabase.co
SUPABASE_ANON_KEY=sb_publishable_xxx    # safe to expose to clients
SUPABASE_SERVICE_KEY=sb_secret_xxx      # server-side only — never expose to clients
import 'dotenv/config';
import { BlinkProvider } from 'l402-kit';

const blink = new BlinkProvider(
  process.env.BLINK_API_KEY!,
  process.env.BLINK_WALLET_ID!,
);

Payment logging (Supabase)

Log every payment to Supabase for the VS Code extension dashboard and analytics:
import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_ANON_KEY!,
);

app.use(async (req, res, next) => {
  const original = res.json.bind(res);
  res.json = (body) => {
    // Log successful L402 payments (after middleware passes)
    if (req.l402Paid && req.l402Preimage) {
      supabase.from('payments').insert({
        endpoint: req.path,
        preimage: req.l402Preimage,
        amount_sats: req.l402AmountSats,
      }).then(() => {});
    }
    return original(body);
  };
  next();
});

Cloudflare Workers deployment

l402-kit API runs on Cloudflare Workers (V8 isolates, zero cold start). The in-memory replay store resets per isolate — for high-traffic APIs, use Cloudflare KV or Durable Objects for replay protection.
// api/data.ts
import { l402 } from 'l402-kit';
import { BlinkProvider } from 'l402-kit';

const blink = new BlinkProvider(
  process.env.BLINK_API_KEY!,
  process.env.BLINK_WALLET_ID!,
);

export default async function handler(req, res) {
  await new Promise<void>((resolve, reject) => {
    l402({ priceSats: 10, lightning: blink })(req, res, (err) => {
      if (err) reject(err); else resolve();
    });
  });
  res.json({ data: 'premium content' });
}

Docker

FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY dist ./dist
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/server.js"]
# docker-compose.yml
services:
  api:
    build: .
    environment:
      BLINK_API_KEY: ${BLINK_API_KEY}
      BLINK_WALLET_ID: ${BLINK_WALLET_ID}
    ports:
      - "3000:3000"

User data deletion

Expose a deletion endpoint so users can permanently remove their data. The l402-kit backend includes /api/delete-data out of the box:
POST /api/delete-data
Content-Type: application/json

{ "lightningAddress": "user@blink.sv" }
// 200 OK
{ "deleted": { "payments": 42, "proAccess": true } }
The VS Code extension surfaces this as a Danger Zone panel at the bottom of the dashboard — users must type their Lightning address to confirm, then all payment history and Pro access are wiped. The operation uses the Supabase service key server-side; the anon key has no DELETE permission.

Error handling

Catch provider errors before they surface as unformatted 500s:
import { l402, L402Error } from 'l402-kit';

app.get('/api/data', l402({ priceSats: 10, lightning }), async (req, res) => {
  try {
    res.json({ data: await fetchData() });
  } catch (err) {
    if (err instanceof L402Error) {
      // Token invalid, expired, or already used — let the middleware handle it
      return res.status(err.status).json({ error: err.code });
    }
    console.error('[api/data]', err);
    res.status(503).json({ error: 'Service temporarily unavailable' });
  }
});
Rules of thumb:
  • Never return stack traces to clients — log them server-side.
  • Provider timeouts (503) are transient — safe to retry with backoff.
  • Token errors (401) are never transient — don’t auto-retry, require a new invoice.
  • Rate limit errors (429) — surface the retryAfter field to the caller.
See Error Reference for the full list of structured error codes.

Rate limiting

Every unauthenticated request triggers createInvoice() on your Lightning provider. Without rate limiting, anyone can flood your endpoint and exhaust your provider’s API quota for free — even without paying a single sat. Add express-rate-limit before your L402 routes:
npm install express-rate-limit
import rateLimit from "express-rate-limit";
import { l402 } from "l402-kit";

// Limit invoice creation: 30 unauthenticated requests/minute per IP
const invoiceLimit = rateLimit({
  windowMs: 60_000,
  max: 30,
  // Only apply to requests that don't already carry a valid L402 header
  skip: (req) => req.headers.authorization?.startsWith("L402 ") ?? false,
  message: { error: "Too many requests — slow down" },
});

app.get("/api/premium", invoiceLimit, l402({ priceSats: 10, lightning }), handler);
Already-paying clients are skipped by the skip function — the limit only applies to unauthenticated calls that trigger a new invoice. Legitimate payers are never throttled.
For FastAPI:
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded

limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)

@app.get("/api/premium")
@limiter.limit("30/minute")
@l402_required(price_sats=10, lightning=lightning)
async def premium(request: Request):
    return {"data": "paid content"}

Health check endpoint

Always add a free health check so monitoring tools don’t trigger invoice creation:
app.get('/health', (req, res) => res.json({ ok: true, ts: Date.now() }));

// Paid endpoint
app.get('/api/data', l402({ priceSats: 10, lightning: blink }), handler);

Monitoring

Key metrics to track:
  • 402 response rate — healthy baseline is high (most callers need to pay)
  • Payment verification rate — ratio of paid vs unpaid calls
  • Provider latencyblink.createInvoice() should be < 500ms
  • Replay attempts — spike indicates token reuse attacks
app.use((req, res, next) => {
  const start = Date.now();
  res.on('finish', () => {
    console.log(JSON.stringify({
      method: req.method,
      path: req.path,
      status: res.statusCode,
      ms: Date.now() - start,
      paid: res.statusCode !== 402,
    }));
  });
  next();
});

Performance

  • Token verification is O(1) — pure crypto, no DB, no network
  • Invoice creation (402 path) calls your Lightning provider API — add a cache if you expect the same endpoint hit repeatedly before payment
  • Replay store is in-memory Set — for multi-instance deployments, swap for Redis
// Redis replay store example
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);

// Pass custom replay function to middleware
app.get('/api', l402({
  priceSats: 10,
  lightning: blink,
  checkReplay: async (preimage) => {
    const set = await redis.set(`l402:${preimage}`, '1', 'NX', 'EX', 86400);
    return set === 'OK';
  },
}), handler);