ngx_l402 — L402 Nginx Module

An L402 authentication module for Nginx that enables Lightning Network-based monetization for your REST APIs (HTTP/1 and HTTP/2).

It supports the following Lightning backends:

Backend Description
LND Lightning Network Daemon (direct gRPC)
LNC Lightning Node Connect (remote LND via mailbox)
CLN Core Lightning
Eclair Eclair node
LNURL Lightning Network URL
NWC Nostr Wallet Connect
BOLT12 Reusable Lightning Offers

The module can be configured to charge per unique API call, enabling per-endpoint monetization based on request paths.

L402 module demo


How It Works

graph TD;
    A[Request Received] --> B{Endpoint L402 Enabled?}
    B -->|No| C[Return 200 OK]
    B -->|Yes| D{"Any auth header present? (L402 or X-Cashu)"}
    D -->|No| F[Generate L402 Header macaroon & invoice]
    D -->|Yes| K["Parse L402 macaroon/preimage or X-Cashu (if present)"]
    F --> G{Header Generation Success?}
    G -->|No| I[Return 500 Internal Server Error]
    G -->|Yes| H[Add WWW-Authenticate Header]
    H --> J[Return 402 Payment Required]
    K --> L{Parse Success?}
    L -->|No| M[Return 500 Internal Server Error]
    L -->|Yes| AD{"Auto-detect enabled AND no preimage in header?"}
    AD -->|Yes| ND[Query Lightning node for settled invoice]
    ND --> NS{Invoice settled?}
    NS -->|No| NR[Return 402 Payment Required]
    NS -->|Yes| NV["Verify macaroon signature (preimage from node)"]
    NV -->|Valid| P[Return 200 OK]
    NV -->|Invalid| Q[Return 401 Unauthorized]
    AD -->|No / preimage provided| N["Verify macaroon/preimage OR Cashu proofs (whitelist; P2PK lock if enabled; double-spend check; amount >= price)"]
    N --> O{Verification Success?}
    O -->|No| Q
    O -->|Yes| P

Auto-detect: When l402_auto_detect_payment on is set and the client sends only Authorization: L402 <macaroon> (no preimage), the server queries the Lightning node directly. Supported on LND, CLN, and Eclair.


Quick Start

Note: This module requires NGINX version 1.28.0 or later.

The fastest way to get started is with Docker:

docker run -d \
  --name l402-nginx \
  -p 8000:8000 \
  -e LN_CLIENT_TYPE=LNURL \
  -e LNURL_ADDRESS=username@your-lnurl-server.com \
  -e ROOT_KEY=your-32-byte-hex-key \
  ghcr.io/dhananjaypurohit/ngx_l402:latest

Then test it:

# Should return 200 OK
curl http://localhost:8000/

# Should return 402 Payment Required with L402 header
curl -i http://localhost:8000/protected

See the Installation section for full setup options.

Manual Installation

Note: This module requires NGINX version 1.28.0 or later. Earlier versions will cause module version mismatch errors.

Steps

1. Download the Module

Download libngx_l402_lib.so from the latest release and copy it to your Nginx modules directory:

sudo cp libngx_l402_lib.so /etc/nginx/modules/

2. Load the Module in nginx.conf

load_module /etc/nginx/modules/libngx_l402_lib.so;

3. Enable L402 for Specific Locations

location /protected {
    root   /usr/share/nginx/html;
    index  index.html index.htm;
    
    # L402 module directives:
    l402 on;
    l402_amount_msat_default    10000;
    # Note: Dynamic pricing is handled via Redis using the request path as key
    # Example: SET /protected 15000 (sets price to 15000 msats for /protected endpoint)
    l402_macaroon_timeout 3600;  # Macaroon validity in seconds, set to 0 to disable timeout
    # Optional: per-location LNURL address for multi-tenant setups
    # l402_lnurl_addr "tenant@your-lnurl-server.com";
}

4. Set Environment Variables

Set the following in nginx.service (typically /lib/systemd/system/nginx.service).

See Environment Variables for the complete reference.

5. Set Up SQLite Database Directory (if using Cashu)

# One-time setup — persists across restarts
sudo mkdir -p /var/lib/nginx
sudo chown nginx:nginx /var/lib/nginx
sudo chmod 755 /var/lib/nginx

The cdk-sqlite crate automatically creates the database file and tables on first run. Database location: /var/lib/nginx/cashu_tokens.db

Note: Both restart and reload are needed to ensure the Cashu redemption task starts properly.

6. Restart Nginx

sudo systemctl restart nginx
sudo systemctl reload nginx

Note: Both restart and reload are needed to ensure the Cashu redemption task starts properly.

macOS Local Setup Guide

This guide is for contributors running ngx_l402 locally on macOS.

1. Prerequisites

  • Docker — make sure the Docker daemon is running.
  • Rust — install via rustup if not already present:
    curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
    

2. Clone and enter the repository

git clone https://github.com/DhananjayPurohit/ngx_l402.git
cd ngx_l402

3. Start the stack

Copy the example env file and start the services:

cp .env.example .env
docker compose up -d bitcoind lndnode-receiver redis grpc-content-server nginx-lnd

The first run compiles the module inside a Linux container (multi-stage Dockerfile), so it will take a few minutes. Subsequent runs use the Docker build cache.

Fund the regtest LND node so it can create invoices:

docker exec bitcoind bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass createwallet miner 2>/dev/null
docker exec bitcoind bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass -rpcwallet=miner generatetoaddress 101 \
  $(docker exec bitcoind bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass -rpcwallet=miner getnewaddress) > /dev/null
sleep 5

4. Verify

curl -i http://localhost:8000/protected

Expected: 402 Payment Required with a WWW-Authenticate: L402 ... header. If the request hangs, wait a few seconds for LND to finish syncing and try again.

5. Development workflow

After editing the Rust source, rebuild and restart nginx:

docker compose build nginx-lnd && docker compose up -d nginx-lnd

Dependencies are cached, so only the module recompiles (~20 seconds).

You can also run cargo check locally for fast feedback from your editor without rebuilding the container.

6. Useful commands

docker compose ps                    # container status
docker logs nginx-lnd -f             # nginx logs
docker compose down                  # stop stack

Docker Installation

The easiest way to deploy the L402 Nginx module is with our official Docker images.

docker pull ghcr.io/dhananjaypurohit/ngx_l402:latest

Quick Start Examples

1. LNURL Backend (Simplest Setup)

docker run -d \
  --name l402-nginx \
  -p 8000:8000 \
  -e LN_CLIENT_TYPE=LNURL \
  -e LNURL_ADDRESS=username@your-lnurl-server.com \
  -e ROOT_KEY=your-32-byte-hex-key \
  ghcr.io/dhananjaypurohit/ngx_l402:latest

2. LND Backend with Cashu Support

mkdir -p ~/l402-data
cp ~/.lnd/data/chain/bitcoin/mainnet/admin.macaroon ~/l402-data/
cp ~/.lnd/tls.cert ~/l402-data/

docker run -d \
  --name l402-nginx \
  -p 8000:8000 \
  -e LN_CLIENT_TYPE=LND \
  -e LND_ADDRESS=your-lnd-ip:10009 \
  -e MACAROON_FILE_PATH=/app/data/admin.macaroon \
  -e CERT_FILE_PATH=/app/data/tls.cert \
  -e CASHU_ECASH_SUPPORT=true \
  -e CASHU_WALLET_MNEMONIC="word1 word2 ... word12" \
  -e CASHU_DB_PATH=/app/data/cashu_tokens.db \
  -e CASHU_WHITELISTED_MINTS=https://mint1.example.com,https://mint2.example.com \
  -e CASHU_REDEEM_ON_LIGHTNING=true \
  -e REDIS_URL=redis://redis:6379 \
  -v ~/l402-data:/app/data \
  ghcr.io/dhananjaypurohit/ngx_l402:latest

3. LND via Lightning Node Connect (LNC)

# Generate a pairing phrase from Lightning Terminal first:
# litcli sessions add --label="nginx-l402" --type=admin

docker run -d \
  --name l402-nginx \
  -p 8000:8000 \
  -e LN_CLIENT_TYPE=LND \
  -e LNC_PAIRING_PHRASE="word1 word2 word3 word4 word5 word6 word7 word8 word9 word10" \
  -e LNC_MAILBOX_SERVER=mailbox.terminal.lightning.today:443 \
  -e ROOT_KEY=your-32-byte-hex-key \
  ghcr.io/dhananjaypurohit/ngx_l402:latest

4. CLN Backend (Core Lightning)

docker run -d \
  --name l402-nginx \
  -p 8000:8000 \
  -e LN_CLIENT_TYPE=CLN \
  -e CLN_LIGHTNING_RPC_FILE_PATH=/app/data/lightning-rpc \
  -e ROOT_KEY=your-32-byte-hex-key \
  -e CASHU_ECASH_SUPPORT=true \
  -e CASHU_WALLET_MNEMONIC="word1 word2 ... word12" \
  -e CASHU_DB_PATH=/app/data/cashu_tokens.db \
  -v ~/.lightning/bitcoin/lightning-rpc:/app/data/lightning-rpc:ro \
  ghcr.io/dhananjaypurohit/ngx_l402:latest

5. NWC Backend (Nostr Wallet Connect)

docker run -d \
  --name l402-nginx \
  -p 8000:8000 \
  -e LN_CLIENT_TYPE=NWC \
  -e NWC_URI=nostr+walletconnect://your-pubkey?relay=wss://relay.damus.io&secret=your-secret \
  -e ROOT_KEY=your-32-byte-hex-key \
  ghcr.io/dhananjaypurohit/ngx_l402:latest

6. High-Performance P2PK Mode (Recommended for Production)

docker run -d \
  --name l402-nginx \
  -p 8000:8000 \
  -e LN_CLIENT_TYPE=LND \
  -e LND_ADDRESS=your-lnd-ip:10009 \
  -e MACAROON_FILE_PATH=/app/data/admin.macaroon \
  -e CERT_FILE_PATH=/app/data/tls.cert \
  -e CASHU_ECASH_SUPPORT=true \
  -e CASHU_P2PK_MODE=true \
  -e CASHU_P2PK_PRIVATE_KEY=your-32-byte-hex-private-key \
  -e CASHU_WALLET_MNEMONIC="word1 word2 ... word12" \
  -e CASHU_DB_PATH=/app/data/cashu_tokens.db \
  -e CASHU_WHITELISTED_MINTS=https://mint1.example.com \
  -e CASHU_REDEEM_ON_LIGHTNING=true \
  -e REDIS_URL=redis://redis:6379 \
  -v ~/l402-data:/app/data \
  ghcr.io/dhananjaypurohit/ngx_l402:latest

7. BOLT12 Backend (Reusable Offers)

docker run -d \
  --name l402-nginx \
  -p 8000:8000 \
  -e LN_CLIENT_TYPE=BOLT12 \
  -e BOLT12_OFFER=lno1... \
  -e CLN_LIGHTNING_RPC_FILE_PATH=/app/data/lightning-rpc \
  -e ROOT_KEY=your-32-byte-hex-key \
  -v ~/.lightning/bitcoin/lightning-rpc:/app/data/lightning-rpc:ro \
  ghcr.io/dhananjaypurohit/ngx_l402:latest

8. Eclair Backend

docker run -d \
  --name l402-nginx \
  -p 8000:8000 \
  -e LN_CLIENT_TYPE=ECLAIR \
  -e ECLAIR_ADDRESS=http://your-eclair-node:8282 \
  -e ECLAIR_PASSWORD=your-eclair-password \
  -e ROOT_KEY=your-32-byte-hex-key \
  ghcr.io/dhananjaypurohit/ngx_l402:latest

Generating Required Secrets

# ROOT_KEY (required for all setups)
openssl rand -hex 32

# CASHU_WALLET_MNEMONIC (for Cashu support): a BIP39 phrase, NOT a random hex.
# Leave it unset to have one generated and saved beside the DB on first run,
# or generate a 12/24-word phrase with any BIP39 tool.

# CASHU_P2PK_PRIVATE_KEY (for P2PK mode)
openssl rand -hex 32

Testing Your Setup

# Test free endpoint
curl http://localhost:8000/

# Test protected endpoint (should return 402 with L402 header)
curl -i http://localhost:8000/protected

# Check container logs
docker logs l402-nginx -f

# Stop the container
docker stop l402-nginx

Specific Versions

docker pull ghcr.io/dhananjaypurohit/ngx_l402:v1.2.5

Environment Variables

All configuration is done via environment variables set in nginx.service (typically at /lib/systemd/system/nginx.service).

[Service]
...
Environment=VAR_NAME=value

Lightning Client Type

Variable Required Description
LN_CLIENT_TYPE One of: LND, CLN, LNURL, NWC, BOLT12, ECLAIR

LND (Direct gRPC)

Environment=LN_CLIENT_TYPE=LND
Environment=LND_ADDRESS=your-lnd-ip.com
Environment=MACAROON_FILE_PATH=/path/to/macaroon
Environment=CERT_FILE_PATH=/path/to/cert
Environment=ROOT_KEY=your-root-key

LND via Lightning Node Connect (LNC)

Environment=LN_CLIENT_TYPE=LND
Environment=LNC_PAIRING_PHRASE=<10-word-mnemonic-from-litd>
Environment=LNC_MAILBOX_SERVER=mailbox.terminal.lightning.today:443
Environment=ROOT_KEY=your-root-key

CLN (Core Lightning)

Environment=LN_CLIENT_TYPE=CLN
Environment=CLN_LIGHTNING_RPC_FILE_PATH=/path/to/lightning-rpc
Environment=ROOT_KEY=your-root-key

LNURL

Environment=LN_CLIENT_TYPE=LNURL
Environment=LNURL_ADDRESS=username@your-lnurl-server.com
Environment=ROOT_KEY=your-root-key

NWC (Nostr Wallet Connect)

Environment=LN_CLIENT_TYPE=NWC
Environment=NWC_URI=nostr+walletconnect://<pubkey>?relay=<relay_url>&secret=<secret>
Environment=ROOT_KEY=your-root-key

BOLT12 (Reusable Offers)

Environment=LN_CLIENT_TYPE=BOLT12
Environment=BOLT12_OFFER=lno1...
Environment=CLN_LIGHTNING_RPC_FILE_PATH=/path/to/lightning-rpc
Environment=ROOT_KEY=your-root-key

How it works: When a client requests a protected resource, the module connects to your CLN node via the Unix socket at CLN_LIGHTNING_RPC_FILE_PATH and calls fetchinvoice to derive a fresh single-use BOLT11 invoice from the reusable BOLT12 offer. The node resolves the offer's embedded node ID and negotiates the payment parameters over the Lightning network automatically. CLN_LIGHTNING_RPC_FILE_PATH is therefore required alongside BOLT12_OFFER.

Eclair

Environment=LN_CLIENT_TYPE=ECLAIR
Environment=ECLAIR_ADDRESS=http://127.0.0.1:8282
Environment=ECLAIR_PASSWORD=eclairpass   # REQUIRED — no default; module disables auto-detect if unset
Environment=ROOT_KEY=your-root-key

⚠️ Security: ECLAIR_PASSWORD is required and has no default value. If it is not set the Eclair payment-detector is disabled at startup and an error is logged. Never use a well-known or placeholder password in production.


Redis (Dynamic Pricing & Replay Protection)

Strongly recommended in production. Without Redis, replay protection uses in-process caching only — it is lost on restart and does not work across multiple nginx workers. Multi-worker deployments require Redis.

Environment=REDIS_URL=redis://127.0.0.1:6379

# Connection pool size (default: 4)
Environment=REDIS_POOL_SIZE=4

# TTL for spent Lightning preimages (default: 86400 = 24 hours)
Environment=L402_PREIMAGE_TTL_SECONDS=86400

# TTL for spent Cashu tokens (default: 86400 = 24 hours)
Environment=L402_CASHU_TOKEN_TTL_SECONDS=86400

Setting TTL to "infinite" (permanent replay protection)

The module stores spent preimages and Cashu tokens in Redis using SET NX EX <seconds>. Redis requires a positive integer for EX — there is no built-in "never expire" option via this command.

To achieve permanent replay protection (strongly recommended in production), set the TTL to a very large value:

# ~68 years — effectively permanent
Environment=L402_PREIMAGE_TTL_SECONDS=2147483647
Environment=L402_CASHU_TOKEN_TTL_SECONDS=2147483647

Trade-off: Permanent keys accumulate in Redis indefinitely. For a busy API with many unique tokens this will grow Redis memory over time. Size each key at ~100 bytes; 1 million spent tokens ≈ 100 MB.

Do not set 0 — Redis rejects EX 0 with an error, which causes the module to fail-open and skip the replay check entirely.


Cashu eCash

Environment=CASHU_ECASH_SUPPORT=true
Environment=CASHU_DB_PATH=/var/lib/nginx/cashu_tokens.db
# BIP39 wallet mnemonic (the Cashu/NUT-13 backup phrase). Leave unset to have one
# generated and saved next to the DB on first run (check the logs for the phrase).
Environment=CASHU_WALLET_MNEMONIC="word1 word2 ... word12"
# Optional: where to persist a generated mnemonic (defaults beside the DB file)
# Environment=CASHU_WALLET_MNEMONIC_FILE=/var/lib/nginx/wallet.mnemonic

# Optional: Whitelist specific mints (comma-separated)
# In standard mode: if not set, all mints are accepted
# In P2PK mode: REQUIRED for security and NUT-24 payment request
Environment=CASHU_WHITELISTED_MINTS=https://mint1.example.com,https://mint2.example.com

# Optional: Auto-redeem Cashu tokens to Lightning
Environment=CASHU_REDEEM_ON_LIGHTNING=true
Environment=CASHU_REDEMPTION_INTERVAL_SECS=3600  # default: 1 hour

⚠️ Security: CASHU_WALLET_MNEMONIC is the BIP39 phrase that derives the wallet seed (NUT-13). It is the only backup of your wallet — anyone with it can steal your tokens, and losing it loses the funds!

  • 12 or 24 English words; restorable in any NUT-13 wallet (nutshell, cashu-ts, cdk-cli)
  • If unset, one is generated and saved beside the DB on first run — back it up
  • On startup the module records a fingerprint of the seed next to the DB and refuses to start if a later mnemonic doesn't match (so a changed/typo'd phrase can't silently orphan a funded wallet); delete the wallet.fingerprint file to switch wallets intentionally
  • Never commit it to Git; keep it in a secrets manager

Redemption Fee Handling

# Minimum balance to attempt melting (default: 10 sats)
Environment=CASHU_MELT_MIN_BALANCE_SATS=10

# Percentage to reserve for fees (default: 1%)
Environment=CASHU_MELT_FEE_RESERVE_PERCENT=1

# Minimum fee reserve when percentage is small (default: 4 sats)
Environment=CASHU_MELT_MIN_FEE_RESERVE_SATS=4

# Maximum proofs per melt operation (default: 0 = unlimited)
# Logic: if proof_count > limit, select first N proofs, rest remain for next cycle
# Use case: prevent hitting mint proof limits (e.g. mint.coinos.io has 1000 proof limit)
Environment=CASHU_MAX_PROOFS_PER_MELT=1000

P2PK Mode (High Performance)

Environment=CASHU_P2PK_MODE=true
Environment=CASHU_P2PK_PRIVATE_KEY=<your-private-key-hex>
# Public key is derived automatically from the private key
# CASHU_WHITELISTED_MINTS is REQUIRED in P2PK mode
Environment=CASHU_REQUIRE_DLEQ=true   # default: true — keep it on

⚠️ Security: CASHU_P2PK_PRIVATE_KEY is equally critical. Anyone with this key can spend tokens locked to your public key!

  • Generate with: openssl rand -hex 32
  • Never commit to Git or share publicly
  • Keep it secure alongside CASHU_WALLET_MNEMONIC

🔒 NUT-12 DLEQ (CASHU_REQUIRE_DLEQ): In P2PK mode, tokens are verified on a fast path that skips the mint swap for lower latency. DLEQ proofs (NUT-12) are what let us confirm offline — using only cached mint keysets — that each proof was actually signed by the whitelisted mint. With this check off, a forger could submit correctly-shaped, mint-whitelisted proofs that the mint never signed and get free service (the operator only finds out at redemption time, when the melt fails).

  • Default true: a proof with no DLEQ data is rejected. Modern Cashu wallets include DLEQ by default, so this is safe.
  • Set CASHU_REQUIRE_DLEQ=false only as a temporary safety valve if a real-world wallet ships DLEQ-less tokens. This is insecure and re-opens the forged-proof window above.
  • The standard (non-P2PK) mode is unaffected: its mint swap already validates proofs authoritatively.

🔒 NUT-12 DLEQ (CASHU_REQUIRE_DLEQ): In P2PK mode, tokens are verified on a fast path that skips the mint swap for lower latency. DLEQ proofs (NUT-12) are what let us confirm offline — using only cached mint keysets — that each proof was actually signed by the whitelisted mint. With this check off, a forger could submit correctly-shaped, mint-whitelisted proofs that the mint never signed and get free service (the operator only finds out at redemption time, when the melt fails).

  • Default true: a proof with no DLEQ data is rejected. Modern Cashu wallets include DLEQ by default, so this is safe.
  • Set CASHU_REQUIRE_DLEQ=false only as a temporary safety valve if a real-world wallet ships DLEQ-less tokens. This is insecure and re-opens the forged-proof window above.
  • The standard (non-P2PK) mode is unaffected: its mint swap already validates proofs authoritatively.

See Cashu eCash for a full explanation of Standard vs P2PK mode and redemption fee examples.


LND via SOCKS5 / Tor proxy

# [Optional] Route LND gRPC through a SOCKS5 proxy
Environment=SOCKS5_PROXY=socks5://127.0.0.1:9050

Capability Manifest Metadata

These optional variables populate the service block in /.well-known/l402-services. All are omitted from the manifest JSON when unset.

Environment=L402_SERVICE_NAME=My API
Environment=L402_SERVICE_DESCRIPTION=Premium data, paid per request.
Environment=L402_SERVICE_OPERATOR=npub1...   # Nostr pubkey, DID, or free-form
Environment=L402_SERVICE_CONTACT=ops@example.com

See Capability Manifest for the full manifest spec.


Logging

Environment=RUST_LOG=info
# For module-specific debug logs:
Environment=RUST_LOG=ngx_l402_lib=debug,info

# Log per-request performance timing (set to any non-empty value to enable)
Environment=L402_PERF_LOG=1

Nginx Location Directives

These are set inside location {} blocks in nginx.conf (not environment variables).

Directive Type Default Description
l402 boolean¹ off Enable L402 protection for this location
l402_amount_msat_default integer Price in millisatoshis (overridden by Redis dynamic pricing)
l402_macaroon_timeout integer (seconds) 0 (disabled) Macaroon validity window; 0 = no expiry
l402_lnurl_addr string Per-location LNURL address for multi-tenant setups
l402_invoice_rate_limit <N>r/m or <N>r/s disabled Max invoice generation rate per IP per route
l402_auto_detect_payment boolean¹ off Server-side payment detection — queries the Lightning node instead of requiring the client to supply the preimage
l402_indefinite_access boolean¹ off Skip the single-use preimage replay check — a single payment stays valid for the macaroon lifetime

¹ Boolean directives accept: on / off / true / false / 1 / 0 / yes / no (case-insensitive).

Example: auto-detect enabled location

location /protected {
    l402                         on;
    l402_amount_msat_default     10000;
    l402_macaroon_timeout        0;
    l402_auto_detect_payment     on;

    try_files /index.html =404;
}

Example: subscription-style (indefinite) access

location /subscriber-only {
    l402                         on;
    l402_amount_msat_default     100000;
    l402_macaroon_timeout        2592000;  # 30 days
    l402_indefinite_access       on;       # single payment stays valid until macaroon expires

    try_files /index.html =404;
}

Warning: l402_indefinite_access on should always be paired with a non-zero l402_macaroon_timeout. Without an expiry, the macaroon never expires and the same preimage grants access forever.

Backends that support auto-detect: LND, CLN, BOLT12, ECLAIR. LNC, NWC, and LNURL do not support server-side lookup.

Redis & Dynamic Configuration

The module supports real-time configuration updates via Redis without requiring an Nginx reload.

Setup

Environment=REDIS_URL=redis://127.0.0.1:6379

Dynamic Pricing

Set the price for a specific path in Redis. Changes are picked up immediately by the next request.

# Set price to 1000 msats for /api/resource
SET /api/resource 1000

# Set price to 5000 msats for /api/premium
SET /api/premium 5000

Note: If no Redis key exists for a path, the module falls back to l402_amount_msat_default in nginx.conf.


Dynamic LNURL (Per-Tenant Routing)

Override the LNURL address for a specific request path. This takes precedence over l402_lnurl_addr in nginx.conf.

Key format: lnurl:<request_path>

# Route /api/tenant1 payments to alice
SET lnurl:/api/tenant1 alice@getalby.com

# Route /api/tenant2 payments to bob
SET lnurl:/api/tenant2 bob@getalby.com

Replay Attack Prevention

Redis is used to enforce single-use of L402 preimages and Cashu tokens, preventing replay attacks across distributed deployments.

Environment=REDIS_URL=redis://127.0.0.1:6379
Environment=L402_PREIMAGE_TTL_SECONDS=86400      # Default: 24 hours
Environment=L402_CASHU_TOKEN_TTL_SECONDS=86400   # Default: 24 hours

How it works: After successful verification, SHA256 hashes of preimages/tokens are stored in Redis with a TTL. Subsequent use of the same credential is rejected with 401. Protection persists across Nginx restarts and works with multiple Nginx instances.


Invoice Rate Limiting

Limits how many invoices (402 responses) a single IP can request per route within a time window. This protects your Lightning node from invoice-spam without affecting clients that hold a valid token.

location /api/resource {
    l402 on;
    l402_amount_msat_default 1000;
    l402_invoice_rate_limit 5r/m;   # 5 invoices per minute per IP
}

Supported formats:

Value Limit
5r/m 5 per minute
10r/h 10 per hour
2r/s 2 per second
5 5 per minute (shorthand)

Requests that exceed the limit receive 429 Too Many Requests with a Retry-After header set to the window duration.

The rate limit only applies to unauthenticated requests (those that would result in a 402). Requests presenting a valid L402 token bypass it entirely.

How it works: Uses a fixed-window Redis counter (INCR + EXPIRE on first hit) keyed by IP and path. Fails open — if Redis is unavailable, rate limiting is disabled and traffic passes through normally.

Multi-Tenant Configuration

The module supports multi-tenant mode, allowing different API routes to use different Lightning/LNURL backends. This is useful for platforms hosting multiple merchants or services, where each tenant receives payments to their own wallet.

Current Support: Multi-tenant is currently supported for Cashu eCash payments only when using LN_CLIENT_TYPE=LNURL.


How It Works

  1. Per-location LNURL addresses: Use the l402_lnurl_addr directive to specify a different LNURL address per Nginx location block.
  2. Proof tracking: When a Cashu token is received, the proofs are mapped to the tenant's LNURL address in Redis.
  3. Grouped redemption: The automatic redemption task groups proofs by tenant and redeems each group to the correct LNURL address.

Nginx Configuration

# Tenant 1 — payments go to alice@getalby.com
location /api/tenant1 {
    l402 on;
    l402_amount_msat_default 10000;
    l402_macaroon_timeout 0;
    l402_lnurl_addr "alice@getalby.com";
}

# Tenant 2 — payments go to bob@getalby.com
location /api/tenant2 {
    l402 on;
    l402_amount_msat_default 15000;
    l402_macaroon_timeout 0;
    l402_lnurl_addr "bob@getalby.com";
}

# Tenant 3 — self-hosted LNURL server
location /api/tenant3 {
    l402 on;
    l402_amount_msat_default 5000;
    l402_macaroon_timeout 0;
    l402_lnurl_addr "user@your-lnurl-server.com";
}

Required Environment Variables

# Use LNURL client type
Environment=LN_CLIENT_TYPE=LNURL

# Default LNURL address (fallback when l402_lnurl_addr is not set)
Environment=LNURL_ADDRESS=default@your-domain.com

# Redis is required for proof-to-tenant mapping
Environment=REDIS_URL=redis://127.0.0.1:6379

# Enable Cashu eCash support
Environment=CASHU_ECASH_SUPPORT=true
Environment=CASHU_WALLET_MNEMONIC="word1 word2 ... word12"
Environment=CASHU_WHITELISTED_MINTS=https://mint.example.com

# Enable automatic redemption to Lightning
Environment=CASHU_REDEEM_ON_LIGHTNING=true
Environment=CASHU_REDEMPTION_INTERVAL_SECS=60

Dynamic LNURL Override via Redis

You can also override the LNURL address per path dynamically without reloading Nginx:

SET lnurl:/api/tenant1 alice@getalby.com
SET lnurl:/api/tenant2 bob@getalby.com

See Redis & Dynamic Config for more details.

Lightning Network Payments

ngx_l402 implements the L402 protocol, enabling API monetization via Lightning Network payments. When a client hits a protected endpoint without a valid token, the module responds with 402 Payment Required and a Lightning invoice. The client pays the invoice, receives a preimage, and presents it alongside the macaroon to gain access.


Supported Backends

Configure the backend via the LN_CLIENT_TYPE environment variable:

LN_CLIENT_TYPE Description
LND Lightning Network Daemon — direct gRPC connection
LNC Lightning Node Connect — remote LND via mailbox (no open port needed)
CLN Core Lightning
ECLAIR Eclair node
LNURL Lightning Network URL — delegate invoice generation to an LNURL server
NWC Nostr Wallet Connect
BOLT12 Reusable Lightning Offers (BOLT12)

See Environment Variables for the full list of per-backend settings.


Payment Flow

  1. Client requests a protected endpoint (no auth header).
  2. Module generates a macaroon and requests an invoice from the configured Lightning backend.
  3. Module responds 402 Payment Required with:
    WWW-Authenticate: L402 macaroon="<macaroon>", invoice="<bolt11>"
    
  4. Client pays the invoice and obtains the preimage.
  5. Client retries with:
    Authorization: L402 <macaroon>:<preimage>
    
  6. Module verifies the macaroon + preimage and returns 200 OK.

Authorization Header Format

Two formats are accepted:

Format Header value When to use
Classic L402 <macaroon>:<preimage_hex> Client has the preimage (standard wallet flow)
Auto-detect L402 <macaroon> Server queries the node; no preimage needed from client

The preimage in the classic format must be the 32-byte (256-bit) hex-encoded payment preimage corresponding to the invoice's payment_hash.


Auto-Detect Payment (Server-Side Settlement Lookup)

With auto-detect enabled the client only needs to send the macaroon — no preimage required. The module queries your Lightning node directly to check whether the invoice is settled and retrieves the preimage from the node.

Enabling auto-detect

Add l402_auto_detect_payment on to any location {} block:

location /protected {
    l402                         on;
    l402_amount_msat_default     10000;
    l402_macaroon_timeout        0;
    l402_auto_detect_payment     on;   # ← enables server-side lookup
}

All boolean directives (l402, l402_auto_detect_payment) accept: on / off / true / false / 1 / 0 / yes / no (case-insensitive).

Client flow with auto-detect enabled

  1. Client requests a protected endpoint → receives 402 Payment Required with a BOLT-11 invoice.
  2. Client pays the invoice (no preimage handling needed).
  3. Client retries with just the macaroon:
    Authorization: L402 <macaroon>
    
  4. Module extracts the payment_hash from the macaroon identifier, queries the node, and — if the invoice is settled — uses the returned preimage to verify the macaroon signature.
  5. On success the module returns 200 OK. If the invoice is not yet settled, it returns 402 Payment Required.

Preimage caching (Redis)

When Redis is configured (REDIS_URL), settled preimages are cached under the key l402:settled:<payment_hash_hex>. Subsequent requests for the same payment hash are served from the cache, avoiding repeated node round-trips.

Backend support matrix

LN_CLIENT_TYPE Auto-detect supported Notes
LND Uses LookupInvoice gRPC
CLN / BOLT12 Uses listinvoices JSON-RPC over unix socket
ECLAIR Uses POST /getreceivedinfo REST API
LNC LNC mailbox does not expose LookupInvoice
NWC lookup_invoice is optional in NIP-47
LNURL Remote wallet — no server-side query API

[!NOTE] Even when l402_auto_detect_payment on is set, the classic L402 <macaroon>:<preimage> format is still accepted — auto-detect only activates when the client omits the preimage.


Wallet Compatibility

[!WARNING] Some wallets (e.g. Wallet of Satoshi) return 48-byte non-standard preimages, which are not compatible with this module. Use a wallet that returns a standard 32-byte preimage.


Also Supported: Cashu eCash

In addition to Lightning, the module accepts Cashu eCash tokens via the X-Cashu header as an alternative payment method. See Cashu eCash Support for details.

Cashu eCash

The module supports Cashu eCash tokens as an alternative payment method to Lightning invoices.


Standard Mode vs P2PK Mode

Standard Mode P2PK Mode
How it works Calls wallet.receive() → contacts mint to swap tokens Verifies token locked to proxy's public key locally
Speed Slower (blocks on mint API call per request) Fast (milliseconds — no mint call!)
Best for Low-traffic or simple setups High-traffic production deployments
Extra requirement None CASHU_WHITELISTED_MINTS is required

Standard Mode Setup

Environment=CASHU_ECASH_SUPPORT=true
Environment=CASHU_DB_PATH=/var/lib/nginx/cashu_tokens.db
# BIP39 wallet mnemonic (NUT-13 backup phrase); unset = auto-generated on first run
Environment=CASHU_WALLET_MNEMONIC="word1 word2 ... word12"

# Optional: Whitelist specific mints (comma-separated)
Environment=CASHU_WHITELISTED_MINTS=https://mint1.example.com,https://mint2.example.com

# Optional: Auto-redeem to Lightning
Environment=CASHU_REDEEM_ON_LIGHTNING=true
Environment=CASHU_REDEMPTION_INTERVAL_SECS=3600

⚠️ Security: CASHU_WALLET_MNEMONIC is the BIP39 phrase that derives the wallet seed (NUT-13) — the only backup of your wallet. Anyone with it can steal your tokens, and losing it loses the funds. It's a 12/24-word phrase (not a hex secret); leave it unset to auto-generate and persist one on first run, and never commit it to Git. On startup the module records a seed fingerprint next to the DB and refuses to start if a later mnemonic doesn't match — delete wallet.fingerprint to switch wallets intentionally.


P2PK Mode Setup (High Performance)

Environment=CASHU_P2PK_MODE=true
Environment=CASHU_P2PK_PRIVATE_KEY=<your-private-key-hex>
# CASHU_WHITELISTED_MINTS is REQUIRED in P2PK mode
Environment=CASHU_WHITELISTED_MINTS=https://mint1.example.com

⚠️ Security: CASHU_P2PK_PRIVATE_KEY is equally critical. Anyone with this key can spend tokens locked to your public key. Generate with openssl rand -hex 32.

How P2PK mode works per request:

  1. Proxy derives a public key from CASHU_P2PK_PRIVATE_KEY and sends it to clients via the X-Cashu header (NUT-24)
  2. Client creates P2PK-locked tokens to that public key
  3. Proxy verifies tokens are locked to its public key (NUT-11) — local check, no network call
  4. Proxy unlocks proofs with private key (local cryptographic operation)
  5. Unlocked proofs are stored directly in CDK database via wallet.receive_proofs()
  6. Background redemption task finds proofs via wallet.get_unspent_proofs() and redeems to Lightning via wallet.melt()

Redemption Fee Configuration

# Minimum balance to attempt melting (default: 10 sats)
Environment=CASHU_MELT_MIN_BALANCE_SATS=10

# Percentage to reserve for fees (default: 1%)
Environment=CASHU_MELT_FEE_RESERVE_PERCENT=1

# Minimum fee reserve when percentage is small (default: 4 sats)
Environment=CASHU_MELT_MIN_FEE_RESERVE_SATS=4

# Maximum proofs per melt operation (default: 0 = unlimited)
# Use this if your mint has a per-melt proof limit (e.g. mint.coinos.io = 1000)
Environment=CASHU_MAX_PROOFS_PER_MELT=1000

Fee calculation: fee_reserve = max(total_amount × percent/100, min_fee_sats)

Example 1 — Large balance (500 sats) with 1% fee reserve:

  • Percentage fee: 500 × 1% = 5 sats
  • Minimum fee: 4 sats
  • Used reserve: max(5, 4) = 5 sats
  • Redeemable: 500 - 5 = 495 sats

Example 2 — Small balance (50 sats) with 1% fee reserve:

  • Percentage fee: 50 × 1% = 0.5 sats
  • Minimum fee: 4 sats
  • Used reserve: max(0.5, 4) = 4 sats ← Minimum kicks in!
  • Redeemable: 50 - 4 = 46 sats

Example 3 — Proof count limiting when exceeding mint limit (CASHU_MAX_PROOFS_PER_MELT=1000):

  • Scenario: 1282 proofs worth 13,588 sats total
  • Check: 1282 proofs > 1000 limit → Limiting triggered
  • Action: Select first 1000 proofs worth ~10,600 sats
  • Invoice: Generate invoice for 10,600 sats
  • Remaining: 282 proofs (~2,988 sats) stay for next cycle
  • Next cycle: 282 proofs < 1000 limit → all remaining proofs melted

Actual melt quote fees are verified against the reserve; warnings appear if the reserve was insufficient.

Note on CASHU_WHITELISTED_MINTS: If not configured, all mints are accepted in standard mode. In P2PK mode, whitelisted mints are REQUIRED for security and the payment request (NUT-24).


SQLite Database Setup

# One-time setup — persists across restarts
sudo mkdir -p /var/lib/nginx
sudo chown nginx:nginx /var/lib/nginx
sudo chmod 755 /var/lib/nginx

The cdk-sqlite crate automatically creates the database file and tables. Database location: /var/lib/nginx/cashu_tokens.db

Building from Source

Prerequisites

Install required system dependencies:

sudo apt-get install -y \
  build-essential \
  clang \
  libclang-dev \
  libc6-dev \
  zlib1g-dev \
  pkg-config \
  libssl-dev \
  protobuf-compiler \
  nginx

Install Rust and Cargo:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Build Steps

  1. Clone the repository:
git clone https://github.com/DhananjayPurohit/ngx_l402.git
cd ngx_l402
  1. Download Nginx source and build the module:

⚠️ Important: You must download the exact Nginx source code matching your target version and set the NGINX_SOURCE_DIR environment variable before building.

# Example for Nginx 1.28.0
curl -fsSL http://nginx.org/download/nginx-1.28.0.tar.gz -o nginx.tar.gz
tar -xzf nginx.tar.gz
cd nginx-1.28.0
./configure --with-compat
cd ..
export NGINX_SOURCE_DIR=$(pwd)/nginx-1.28.0

cargo build --release --features export-modules

The compiled module will be at target/release/libngx_l402_lib.so.

  1. Copy to your Nginx modules directory:
sudo cp target/release/libngx_l402_lib.so /etc/nginx/modules/
  1. Follow the remaining manual installation steps.

Logging

View Logs

systemd / Manual Install

# Module initialization and system logs
sudo journalctl -u nginx

# Nginx error logs (real-time)
sudo tail -f /var/log/nginx/error.log

# Cashu redemption logs
sudo tail -f /var/log/nginx/cashu_redemption.log

Docker

docker logs l402-nginx -f

Log Levels

Control verbosity via the RUST_LOG environment variable:

# Standard info logs (recommended for production)
Environment=RUST_LOG=info

# Detailed debug logs for all modules
Environment=RUST_LOG=debug

# Module-specific debug logs only (reduces noise)
Environment=RUST_LOG=ngx_l402_lib=debug,info

Dry-Run (Shadow) Mode

Shadow mode lets operators roll out L402 enforcement safely. With l402_dry_run on; set on a location, the module evaluates the full pricing pipeline, synthesises a valid L402 challenge, and records structured logs and Prometheus metrics — but always passes the request through to the upstream. No client ever sees 401 or 402.

This is the recommended way to validate pricing, LN backend reachability, and traffic patterns with real production traffic before flipping a route to enforcement.


Enabling shadow mode

location /api/ {
    l402                        on;
    l402_amount_msat_default    10000;
    l402_dry_run                on;          # evaluate, log, never block
    proxy_pass                  http://upstream;
}

l402_dry_run accepts on or off (default). It can be combined with any other l402_* directive — dynamic pricing from Redis, multi-tenant LNURLs, macaroon timeouts, invoice rate limits — so the shadow-mode numbers you measure match the configuration you are about to enforce.

l402 must still be on for the module to enter the access handler. Turning l402 off disables the module entirely, including shadow mode.


What happens per request

For every request reaching a shadow-mode location, the module:

  1. Reads the static and dynamic (Redis) price for the route and picks the effective amount_msat.
  2. Looks up any per-tenant LNURL override.
  3. Verifies the Authorization header if one is present (L402 or Cashu).
  4. If no valid token is present, calls the configured LN backend and generates a real invoice + macaroon — exactly the challenge enforce mode would have returned.
  5. Emits a structured JSON log line and bumps the relevant Prometheus counters.
  6. Returns NGX_DECLINED, so Nginx continues to the content phase and serves the upstream response with its natural status code.

Cost note: generating a challenge contacts your LN backend on every unauthenticated request. If you have high traffic, start by enabling shadow mode on a sampled location (e.g. a canary route) before rolling it out everywhere.

Latency cap: the challenge-synthesis call is bounded by a 5-second timeout. If the LN backend does not respond within that window the request still passes through (with no X-L402-Dry-Run-Challenge header) and l402_dry_run_challenge_errors_total is incremented — shadow mode must never add latency to user-facing traffic.


Response headers

Shadow mode attaches debug headers to the upstream response so operators can inspect what would have happened without scraping logs:

Header Meaning
X-L402-Dry-Run: 1 Marks the response as produced by shadow mode. Always present.
X-L402-Dry-Run-Price-Msat: <n> Effective price for this route. Only emitted when the request would have been challenged (402) — not on paid-valid or rejected-invalid responses, to avoid leaking pricing against decided traffic.
X-L402-Dry-Run-Challenge: L402 macaroon="...", invoice="..." The exact WWW-Authenticate value enforce mode would have returned. Only present when the request would have been challenged (402) and the LN backend produced an invoice.
WWW-Authenticate: L402 macaroon="...", invoice="..." Also set alongside the challenge header, so real L402 clients can follow the payment flow in a staging environment.
X-L402-Dry-Run-Rate-Limited: 1 + X-L402-Dry-Run-Retry-After: <sec> Set when the request would have been challenged but hit l402_invoice_rate_limit. No invoice is generated and no challenge header is attached, mirroring what enforce mode would have done (429 + Retry-After).

Structured log events

Every shadow-mode request produces a single info-level JSON line via the Rust logger. A minimal example (formatted for readability):

{
  "event": "l402_dry_run",
  "route": "/api/resource",
  "price_msat": 10000,
  "price_source": "static",
  "backend": "LNURL",
  "client_ip": "203.0.113.42",
  "auth_state": "missing",
  "would_return": 402
}

Fields:

Field Values
route Normalised request path used for pricing lookups.
price_msat Effective price in millisatoshis.
price_source static (from nginx.conf) or dynamic (from Redis).
backend LN backend type snapshot: LND, LNURL, NWC, CLN, BOLT12, ECLAIR.
client_ip From X-Real-IPX-Forwarded-For → socket address.
auth_state missing, valid, or invalid.
would_return HTTP status enforce mode would have used (200, 401, 402).
rate_limited true when l402_invoice_rate_limit would have produced a 429 — challenge synthesis was skipped to protect the LN backend.

Pipe into jq to see a live firehose:

sudo tail -f /var/log/nginx/error.log \
  | grep '"event":"l402_dry_run"' \
  | jq -c 'select(.would_return != 200) | {route, price_msat, auth_state}'

Prometheus metrics

The l402_metrics directive turns a location into a Prometheus scrape endpoint. It serves counters in text exposition format v0.0.4.

location = /metrics {
    l402_metrics;

    # Production: restrict to your scrape network.
    allow 10.0.0.0/8;
    deny  all;
}

Scrape it with a standard Prometheus config:

scrape_configs:
  - job_name: ngx_l402
    metrics_path: /metrics
    static_configs:
      - targets: ['nginx:8000']

Counters are kept in an nginx shared-memory zone, so they are aggregated across all worker processes: a scrape served by any worker returns the true total regardless of worker_processes. (If the shared zone cannot be allocated at startup the module logs a warning and falls back to per-worker counters, which under-report by roughly a factor of worker_processes.)

Exported counters

Metric Meaning
l402_requests_total Every request that entered the access handler with l402 on;. Incremented for both enforce and shadow traffic.
l402_challenges_issued_total Requests that received a 402 response (enforce mode), counted after the rate-limit gate.
l402_rate_limited_total Requests rejected with 429 by l402_invoice_rate_limit (enforce mode).
l402_payments_valid_total Authorization headers that verified successfully — Lightning + Cashu (enforce mode only — dry-run traffic goes to l402_dry_run_*).
l402_payments_lightning_total Successful payments settled via a Lightning macaroon (classic preimage or auto-detect).
l402_payments_cashu_total Successful payments settled via a Cashu token redemption.
l402_payments_invalid_total Authorization headers that failed verification (enforce mode only).
l402_payments_missing_total Requests without an Authorization header (enforce mode only).
l402_invoices_generated_total Lightning invoices successfully generated for L402 challenges (enforce mode only).
l402_invoices_generation_errors_total Failures generating a Lightning invoice during challenge synthesis (returns 500).
l402_dry_run_requests_total Requests handled in shadow mode.
l402_dry_run_would_block_total Shadow-mode requests that would have been blocked (401 or 402).
l402_dry_run_would_allow_total Shadow-mode requests that would have been allowed (200).
l402_dry_run_rate_limited_total Shadow-mode requests that would have hit l402_invoice_rate_limit — challenge synthesis was skipped.
l402_dry_run_challenge_errors_total Shadow-mode requests where challenge synthesis failed (e.g. LN backend unreachable).
l402_dry_run_price_msat_sum Sum of msat prices evaluated in shadow mode. Pair with _requests_total to derive an average price.

Useful PromQL

# Fraction of traffic that would be blocked if you flipped enforcement on:
rate(l402_dry_run_would_block_total[5m])
/
rate(l402_dry_run_requests_total[5m])

# Average price served by shadow mode (msat):
rate(l402_dry_run_price_msat_sum[5m])
/
rate(l402_dry_run_requests_total[5m])

# Challenge-synthesis error rate — a signal that your LN backend is flaky:
rate(l402_dry_run_challenge_errors_total[5m])

The endpoint has no built-in authentication. Restrict it at the Nginx level with allow/deny, an auth subrequest, or a firewall rule — exposing it publicly leaks traffic volume and pricing details.


Suggested rollout recipe

  1. Deploy with l402 on; and l402_dry_run on; on the target location. Leave existing routes untouched.
  2. Scrape /metrics for 24–48 hours. Confirm:
    • l402_dry_run_challenge_errors_total stays flat (LN backend healthy).
    • l402_dry_run_would_allow_total / l402_dry_run_requests_total matches the fraction of paying clients you expect.
    • l402_dry_run_price_msat_sum divided by request count matches your posted price.
  3. Sample the JSON log for a few high-volume paths and confirm price_source is what you configured (static vs dynamic).
  4. Remove l402_dry_run on; (or set it to off). Reload Nginx. The location now enforces.

If you ever need to revert, setting l402_dry_run on; again immediately disables enforcement without touching upstream code paths.

Capability Manifest

The l402_manifest directive turns a location into a discovery endpoint that emits a JSON description of every L402-protected route on the server. It is intended to live at /.well-known/l402-services (RFC 8615), making this instance self-describing to clients that have only the host.

location = /.well-known/l402-services {
    l402_manifest;

    # Optional: restrict who can scrape pricing details.
    # allow 10.0.0.0/8;
    # deny  all;
}

This is the agent-era equivalent of robots.txt or security.txt. An autonomous agent (or any client) given only https://example.com can fetch the manifest, learn which routes are paid, how much they cost, and which payment backends are accepted — without any out-of-band integration.


Example response

{
  "version": "1",
  "service": {
    "name": "Example API",
    "description": "Stock data API"
  },
  "payment_methods": [
    {
      "type": "lightning",
      "backend": "LNURL",
      "address": "hello@getalby.com"
    },
    {
      "type": "cashu",
      "mints": ["https://mint.minibits.cash"],
      "p2pk_supported": true,
      "challenge_header": "X-Cashu"
    }
  ],
  "routes": [
    {
      "path": "/protected",
      "price": {
        "type": "static",
        "amount_msat": 10000
      },
      "caveats_required": ["RequestPath = /protected"]
    },
    {
      "path": "/rate-limited",
      "price": {
        "type": "static",
        "amount_msat": 10000
      },
      "caveats_required": ["RequestPath = /rate-limited"],
      "rate_limit": {
        "max_requests": 2,
        "window_secs": 60
      }
    }
  ]
}

What the manifest describes

Field Source Meaning
version constant "1" Schema version. Bumped on breaking changes; agents should reject unknown majors.
service.name, service.description, service.operator, service.contact env vars L402_SERVICE_NAME, L402_SERVICE_DESCRIPTION, L402_SERVICE_OPERATOR, L402_SERVICE_CONTACT Optional, omitted when unset.
payment_methods[].type lightning or cashu Which payment rail this method describes.
payment_methods[].backend env var LN_CLIENT_TYPE LNURL, LND, CLN, NWC, BOLT12, ECLAIR, LNC.
payment_methods[].address env var LNURL_ADDRESS (LNURL backends only) Server-default LN address. May be overridden per-route via lnurl_addr.
payment_methods[].mints env var CASHU_WHITELISTED_MINTS Allowed Cashu mints (when Cashu is enabled).
payment_methods[].p2pk_supported env var CASHU_P2PK_MODE Whether NUT-24 P2PK Cashu is enabled.
routes[].path location directive URL path served by this route.
routes[].price.amount_msat l402_amount_msat_default Base price after merge_loc_conf.
routes[].caveats_required derived Caveats the issued macaroon will carry. Today always [RequestPath = <path>].
routes[].macaroon_timeout_secs l402_macaroon_timeout Omitted when 0 (no expiry).
routes[].lnurl_addr l402_lnurl_addr Per-route LNURL override for multi-tenant deployments.
routes[].rate_limit l402_invoice_rate_limit Server-side invoice rate limit applied before challenge issuance.
routes[].auto_detect_payment l402_auto_detect_payment When true, clients can omit the preimage and the server settles via node lookup.

Dynamic (Redis-backed) pricing is not reflected in price.amount_msat — the manifest emits the static default. Dynamic prices change per request and would require a Redis round-trip per route to render accurately; that's out of scope for v1.


Hiding a route

Operators may want certain paid routes to remain undiscoverable — private APIs, beta tiers, customer-specific endpoints. Use l402_manifest_hide; on the location:

location /internal-paid {
    l402 on;
    l402_amount_msat_default 100000;
    l402_manifest_hide;       # not advertised in /.well-known/l402-services
}

The route still enforces L402 normally. It just doesn't appear in the manifest's routes[] array.


Service-level metadata

The optional service block is read from environment variables at manifest-render time:

L402_SERVICE_NAME="Example API"
L402_SERVICE_DESCRIPTION="Premium financial data, paid per request."
L402_SERVICE_OPERATOR="npub1abcd..."   # Nostr pubkey, DID, or free-form
L402_SERVICE_CONTACT="ops@example.com"

All four are optional. Unset variables are omitted from the response so the manifest stays valid JSON even with no service metadata.


Caveats and limitations

  • Per-worker registry. The manifest registry is per-nginx-worker. On a multi-worker deployment, every worker sees the same routes (config is shared), so this is a non-issue for the manifest itself. (Unlike the l402_metrics counters, which use a shared-memory zone, the registry is read-only per-worker state and needs no cross-worker aggregation.)
  • No authentication by default. Pricing information is public. Restrict the endpoint with allow/deny, an auth subrequest, or a firewall if competitors should not see your full pricing matrix.
  • Reload behaviour. On nginx -s reload, new workers start with a fresh registry built from the new config. Old workers serve in-flight requests with their existing registry until they exit.

Why this matters

Without a manifest, every L402 integration is a bespoke wiring job: the client must be told the routes, prices, payment backends, and caveat formats out of band. With one, an agent can land on a host and onboard itself end-to-end:

GET /.well-known/l402-services  → learn the API surface
GET /protected                → receive 402 + bolt11 invoice
PAY the invoice               → get preimage
GET /protected with L402 auth → success

For autonomous agents — Claude tools, MCP servers, custom-built — this is the difference between L402 being a protocol and L402 being a discoverable web standard.