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¶
- L402 spec (LSAT)
LightningFundingSource,LndClient,Funding.lightning_lndin API Reference- Concepts: Payment Rails