Skip to content

L402 — Lightning

Pay a paywalled API with BOLT-11 Lightning invoices using the L402 protocol. Routeweiler connects to your LND node via gRPC to pay invoices automatically.

Prerequisites

Requirement Notes
LND node Any LND node (local, Umbrel, Voltage, etc.)
Channel with liquidity Outbound liquidity to route sats to the merchant
TLS cert + admin macaroon From ~/.lnd/tls.cert and ~/.lnd/data/chain/bitcoin/mainnet/admin.macaroon
L402-capable endpoint A server that speaks L402 (sends WWW-Authenticate: L402 macaroon=..., invoice=...)

Local development with Polar

Polar runs a local Lightning regtest network — ideal for testing without real funds.

# Start Polar with two LND nodes (payer + merchant), open a channel between them.
# Export the env vars Polar shows in the node details panel:
export POLAR_LND_HOST=127.0.0.1:10001
export POLAR_LND_TLS_CERT=/path/to/payer/tls.cert
export POLAR_LND_MACAROON=/path/to/payer/admin.macaroon

Synchronous factory (pubkey known)

import asyncio
import os
from routeweiler import Routeweiler, Funding
from routeweiler.funding.lightning import LndClient
from routeweiler.trace.sink_sqlite import TraceSink

async def main():
    client = LndClient(
        grpc_host=os.environ.get("POLAR_LND_HOST", "localhost").split(":")[0],
        grpc_port=int(os.environ.get("POLAR_LND_HOST", "localhost:10001").split(":")[1]),
        tls_cert_path=os.environ.get("POLAR_LND_TLS_CERT"),
        macaroon_path=os.environ.get("POLAR_LND_MACAROON"),
    )

    # If you know the node pubkey, use the synchronous factory:
    funding = Funding.lightning_lnd(
        client=client,
        network="bitcoin-regtest",    # "bitcoin" for mainnet
        node_pubkey="02abc...",        # 66-char hex compressed pubkey
    )

    async with Routeweiler(
        funding=[funding],
        trace_sink=TraceSink.sqlite("rw.db"),
    ) as rw:
        response = await rw.get("http://YOUR_L402_ENDPOINT/protected")
        print(f"HTTP {response.status_code}: {response.json()}")

asyncio.run(main())

Async factory (auto-fetches pubkey)

If you don't have the pubkey at hand, LightningFundingSource.create() calls getinfo on the node to fetch it:

from routeweiler.funding.lightning import LightningFundingSource, LndClient

lnd = LndClient(
    grpc_host="localhost",
    grpc_port=10001,
    tls_cert_path="/path/to/tls.cert",
    macaroon_path="/path/to/admin.macaroon",
)
funding = await LightningFundingSource.create(lnd, "bitcoin-regtest")

With a USD budget envelope

L402 payments are in sats. Routeweiler uses CoinGecko to convert the sats amount to your envelope's reference currency at draw time.

from routeweiler import Routeweiler, BudgetEnvelope
from routeweiler.trace.sink_sqlite import TraceSink

async with Routeweiler(
    funding=[funding],
    trace_sink=TraceSink.sqlite("rw.db"),
    budget_envelope=BudgetEnvelope(
        id="lightning-session",
        cap_minor_units=1_000_000,  # $10,000.00 in cents (generous for testing)
        cap_currency="usd",
        allowed_rails=["l402"],
        ttl_seconds=3_600,
    ),
) as rw:
    response = await rw.get("http://polar-server/protected")

The fmvQuality in the trace will be "coingecko_simple" — the BTC/USD rate was fetched live. Pass a custom fmv_provider to Routeweiler(...) if you want a static rate in tests.

What the flow looks like

GET http://server/protected
  ← 402 Payment Required
    WWW-Authenticate: L402 macaroon="<b64>", invoice="lnbc100n..."

  Routeweiler:
    1. Parses L402 challenge (macaroon + BOLT-11 invoice)
    2. Checks policy and budget
    3. Calls lnd.send_payment_sync(payment_request=invoice)
       → LND pays the invoice over Lightning
       → Returns preimage (32 bytes)
    4. Sends Authorization: L402 <macaroon>:<preimage_hex>

GET http://server/protected
  Authorization: L402 <macaroon>:<preimage_hex>
  ← 200 OK (server verified SHA-256(preimage) == payment_hash)

What to check in the trace

import sqlite3, json

conn = sqlite3.connect("rw.db")
rows = conn.execute(
    "SELECT * FROM trace_events WHERE selected_rail = 'l402'"
).fetchall()
conn.close()

for row in rows:
    p = json.loads(row["payload"])
    payment = p["payment"]
    print(f"Rail: {row['selected_rail']}")
    print(f"Preimage: {payment['proofValue']}")       # 64-char hex
    print(f"Amount: {payment['amountNative']} sats")
    print(f"Latency: {payment['settlementLatencyMs']} ms")

proofType will be "preimage". The preimage is the SHA-256 preimage of the invoice's payment hash — the cryptographic proof of payment.

Error handling

from routeweiler import InvoicePaymentError, NoFeasibleRailError

try:
    response = await rw.get(url)
except InvoicePaymentError as exc:
    # LND rejected or timed out on the payment
    print(f"Lightning payment failed: {exc}")
except NoFeasibleRailError:
    # No matching L402 funding source, or policy blocked it
    print("No L402 funding source available.")

Mainnet

For mainnet LND, change network="bitcoin" in Funding.lightning_lnd(...). Ensure you have a funded channel with sufficient outbound liquidity for the invoice amounts you expect to pay.

See also