Skip to main content

L402 Lightning paywalls for FastAPI — monetize any API endpoint with Bitcoin Lightning

Project description

⚡ lightning-toll (Python)

Drop-in FastAPI dependency that puts any API endpoint behind a Lightning paywall. Consumers pay per request with Bitcoin Lightning — no API keys to manage, no billing system, no Stripe integration. Just pip install lightning-toll, wrap your routes, and start earning sats.

Implements the L402 protocol with proper macaroon credentials. Wire-format compatible with the Node.js lightning-toll package — same macaroon format, same headers, fully interoperable.

Installation

pip install lightning-toll

# With FastAPI support (recommended)
pip install "lightning-toll[fastapi]"

# For development
pip install "lightning-toll[dev]"

Quick Start

Server (5 lines)

from fastapi import Depends, FastAPI
from lightning_toll import create_toll

app = FastAPI()
toll = create_toll(wallet_url="nostr+walletconnect://...", secret="your-hmac-secret")

@app.get("/api/joke")
async def joke(payment=Depends(toll(sats=5))):
    return {"joke": "Why do programmers prefer dark mode? Light attracts bugs."}

Client (3 lines)

from lightning_toll.client import toll_fetch

response = await toll_fetch("https://api.example.com/api/joke", wallet_url="nostr+walletconnect://...")
data = response.json()  # Paid 5 sats automatically

How It Works — L402 Protocol

Client                                Server
  |                                      |
  |  GET /api/joke                       |
  |  ─────────────────────────────────>  |
  |                                      |
  |  402 Payment Required                |
  |  WWW-Authenticate: L402 invoice="..",|
  |    macaroon=".."                     |
  |  <─────────────────────────────────  |
  |                                      |
  |  [Pays Lightning invoice]            |
  |  [Gets preimage as receipt]          |
  |                                      |
  |  GET /api/joke                       |
  |  Authorization: L402 <mac>:<preimage>|
  |  ─────────────────────────────────>  |
  |                                      |
  |  200 OK { joke: "..." }             |
  |  <─────────────────────────────────  |
  1. Client requests an endpoint without payment
  2. Server returns 402 Payment Required with a Lightning invoice and a macaroon
  3. Client pays the invoice with any Lightning wallet
  4. Client retries with Authorization: L402 <macaroon>:<preimage>
  5. Server verifies the preimage matches the payment hash, checks the macaroon, and grants access

API Reference

create_toll(**options) → Toll

Creates a toll booth instance. Returns a Toll object for creating per-route dependencies.

from lightning_toll import create_toll

toll = create_toll(
    # Required (one of)
    wallet_url="nostr+walletconnect://...",  # NWC connection string
    # wallet=my_wallet_instance,            # Or pre-created wallet

    # Required
    secret="hmac-signing-secret",            # For macaroon HMAC signatures

    # Optional
    default_sats=10,       # Default price if not set per-route (default: 10)
    invoice_expiry=300,    # Invoice expiry in seconds (default: 300 = 5 min)
    macaroon_expiry=3600,  # How long a paid macaroon stays valid (default: 3600 = 1 hour)
    bind_endpoint=True,    # Bind macaroons to the specific endpoint (default: True)
    bind_method=True,      # Bind macaroons to the HTTP method (default: True)
    bind_ip=False,         # Bind macaroons to client IP (default: False)

    # Callbacks
    on_payment=lambda info: print(f"Paid: {info['amount_sats']} sats"),
)

toll(**route_options) → FastAPI Dependency

Create a FastAPI dependency for a route. Use with Depends().

from fastapi import Depends

# Fixed price
@app.get("/api/data")
async def data(payment=Depends(toll(sats=21))):
    return {"data": "..."}

# Dynamic price based on request
@app.get("/api/search")
async def search(payment=Depends(toll(
    price=lambda req: 50 if req.query_params.get("premium") else 10,
    description=lambda req: f"Search: {req.query_params.get('q', '')}"
))):
    return {"results": []}

# Free tier + paid
@app.get("/api/data")
async def data(payment=Depends(toll(
    sats=21,
    free_requests=10,     # Free requests per window per client
    free_window="1h"      # Window: '30m', '1h', '1d', etc.
))):
    return {"data": "..."}

Route Options

Option Type Description
sats int Fixed price in satoshis
price (request) → int Dynamic pricing function
description str | (request) → str Invoice description
free_requests int Free requests per window per client
free_window str | int Free tier window ('1h', '30m', '1d', or milliseconds)

Payment Info

The dependency returns a dict with payment info:

@app.get("/api/data")
async def data(payment=Depends(toll(sats=5))):
    if payment["paid"]:
        print(payment["payment_hash"])
        print(payment["amount_sats"])
    if payment.get("free"):
        print("Free tier request")
    return {"data": "..."}

toll.require(**options) — Decorator Style

@app.get("/api/data")
@toll.require(sats=5)
async def data(request: Request, payment: dict = None):
    return {"data": "..."}

Note: When using the decorator, include request: Request as a parameter. Payment info is injected as payment if the parameter exists.

toll.dashboard_data() — Stats

@app.get("/api/stats")
async def stats():
    return toll.dashboard_data()

Returns:

{
  "totalRevenue": 1250,
  "totalRequests": 340,
  "totalPaid": 125,
  "uniquePayers": 42,
  "endpoints": {
    "/api/joke": { "revenue": 500, "requests": 100, "paid": 100, "free": 0 }
  },
  "recentPayments": [
    {
      "endpoint": "/api/joke",
      "amountSats": 5,
      "payerId": "203.0.113.1",
      "paymentHash": "abc123...",
      "timestamp": 1706817600000
    }
  ]
}

Client SDK

TollClient

A client that automatically handles L402 payment flows:

from lightning_toll.client import TollClient

client = TollClient(
    wallet_url="nostr+walletconnect://...",
    max_sats=100,       # Budget cap per request (default: 100)
    auto_retry=True,    # Auto-pay and retry on 402 (default: True)
    headers={"User-Agent": "MyApp/1.0"}
)

# Transparent fetch — handles 402 automatically
response = await client.fetch("https://api.example.com/joke")
data = response.json()

# Per-request budget override
response = await client.fetch("https://api.example.com/expensive", max_sats=500)

# Check spending
print(client.get_stats())

# Clean up
await client.close()

toll_fetch(url, **options)

One-shot fetch with auto-payment — no client setup needed:

from lightning_toll.client import toll_fetch

response = await toll_fetch(
    "https://api.example.com/joke",
    wallet_url="nostr+walletconnect://...",
    max_sats=50
)
data = response.json()

Options

Option Type Default Description
wallet_url str required* NWC connection string
wallet object Pre-created wallet instance
max_sats int 50 Max sats to auto-pay
method str "GET" HTTP method
headers dict {} Request headers
body any Request body

NWC Wallet Setup

lightning-toll uses Nostr Wallet Connect (NWC) to create invoices and process payments. You need an NWC-compatible Lightning wallet:

Recommended: Alby Hub

  1. Sign up at getalby.com
  2. Go to Settings → Wallet Connections → Add Connection
  3. Copy the NWC URL (starts with nostr+walletconnect://)

Other NWC Wallets

  • LNbits with NWC extension
  • Mutiny Wallet
  • Any wallet implementing NIP-47

Using the NWC Client Directly

from lightning_toll.nwc import NwcWallet

wallet = NwcWallet("nostr+walletconnect://...")

# Create an invoice
result = await wallet.create_invoice(amount_sats=100, description="Test")
print(result.invoice)       # lnbc...
print(result.payment_hash)  # hex

# Check if paid
lookup = await wallet.lookup_invoice(result.payment_hash)
print(lookup.paid)

# Wait for payment
result = await wallet.wait_for_payment(payment_hash, timeout_ms=60000)

await wallet.close()

Macaroon System

Macaroons are bearer credentials with embedded restrictions (caveats). lightning-toll uses HMAC-SHA256 chained signatures, identical to the Node.js version.

How Macaroons Work

1. Server creates macaroon:
   HMAC(secret, paymentHash) → sig₁
   HMAC(sig₁, "expires_at = 1706900000") → sig₂
   HMAC(sig₂, "endpoint = /api/joke") → final_signature

2. Macaroon = { id: paymentHash, caveats: [...], signature: final_sig }
   Encoded as base64url JSON for transport.

3. Verification: recompute the HMAC chain and compare signatures (timing-safe).

Supported Caveats

Caveat Description Default
expires_at Unix timestamp — macaroon expires after this Always set
endpoint Path the macaroon is valid for Set when bind_endpoint=True
method HTTP method restriction Set when bind_method=True
ip Client IP restriction Set when bind_ip=True

Using Macaroons Directly

from lightning_toll import create_macaroon, decode_macaroon, verify_macaroon, verify_preimage

# Create
mac = create_macaroon("secret", payment_hash="abc123...", expires_at=1706900000)
print(mac.raw)  # base64url encoded

# Decode
decoded = decode_macaroon(mac.raw)
print(decoded.id)       # payment hash
print(decoded.caveats)  # list of caveat strings

# Verify
result = verify_macaroon("secret", decoded, {"endpoint": "/api/data"})
print(result.valid)  # True/False
print(result.error)  # Error message if invalid

# Verify preimage
valid = verify_preimage(preimage_hex, payment_hash_hex)

402 Response Format

When a client hits a toll-gated endpoint without payment:

HTTP/1.1 402 Payment Required
WWW-Authenticate: L402 invoice="lnbc50n1pj...", macaroon="eyJpZCI..."
Content-Type: application/json

{
  "status": 402,
  "message": "Payment Required",
  "paymentHash": "a1b2c3d4...",
  "invoice": "lnbc50n1pj...",
  "macaroon": "eyJpZCI...",
  "amountSats": 5,
  "description": "Random joke",
  "protocol": "L402",
  "instructions": {
    "step1": "Pay the Lightning invoice above",
    "step2": "Get the preimage from the payment receipt",
    "step3": "Retry the request with header: Authorization: L402 <macaroon>:<preimage>"
  }
}

Node.js Interoperability

This Python package produces identical wire format to the Node.js lightning-toll:

  • Same base64url JSON macaroon encoding
  • Same HMAC-SHA256 chained signature algorithm
  • Same caveat format (key = value)
  • Same L402 header format
  • Same 402 response body structure

A macaroon created by the Node.js server can be verified by the Python server and vice versa (given the same secret). Clients written for either version work with both servers.

Security Considerations

  • Use a strong secret. At least 32 random characters: python -c "import secrets; print(secrets.token_hex(32))"
  • HTTPS in production. Macaroons and preimages are bearer credentials.
  • Invoice expiry. Default 5 minutes. Shorter = safer.
  • Macaroon expiry. Default 1 hour. A paid macaroon can be reused within this window.
  • IP binding. Enable bind_ip=True to tie macaroons to client IPs. Beware of NAT/proxies.

Demo

Run the included demo server:

pip install -e ".[dev]"

# With mock wallet (for testing the L402 flow)
python examples/fastapi_demo.py

# With real wallet
NWC_URL="nostr+walletconnect://..." python examples/fastapi_demo.py

Open http://localhost:8402 for the welcome page, then try:

curl http://localhost:8402/api/joke    # → 402 + invoice
curl http://localhost:8402/api/stats   # → revenue dashboard

Why Lightning Instead of API Keys?

API Keys / Stripe lightning-toll
Setup Hours–days Minutes (5 lines of code)
User friction Sign up, credit card Scan QR, pay instantly
Minimum payment $0.50+ 1 sat (~$0.0005)
Chargebacks Yes No — Lightning is final
KYC Yes No
Global Restricted Works everywhere, instantly
Privacy Full identity Pseudonymous
Settlement Days–weeks Instant

License

MIT — Jeletor

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

lightning_toll-0.1.1.tar.gz (30.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

lightning_toll-0.1.1-py3-none-any.whl (26.3 kB view details)

Uploaded Python 3

File details

Details for the file lightning_toll-0.1.1.tar.gz.

File metadata

  • Download URL: lightning_toll-0.1.1.tar.gz
  • Upload date:
  • Size: 30.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.9.23

File hashes

Hashes for lightning_toll-0.1.1.tar.gz
Algorithm Hash digest
SHA256 f29d88329741206421a098184c02f44f0fd970998bb3e2c3148680a1d0eba5a4
MD5 7b2c9362c449f27ca8f8e36160ece65b
BLAKE2b-256 097f68ffc1c992bb054a98fe83f40edda5e33b1d7e05f51778e4e43b562e57ce

See more details on using hashes here.

File details

Details for the file lightning_toll-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: lightning_toll-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 26.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.9.23

File hashes

Hashes for lightning_toll-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 fe20ac0aa405752c9a97f6bb0ff95c1746c9ab4b310ed7320a37671c13fb7319
MD5 2ce6174988b65f8054ca1e9f0570d2ce
BLAKE2b-256 97056b5c66163f934040d5ae0d062571e2bec95842a2b7c22af6da5e0dd3ea3c

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page