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.
pip install lightning-toll
# With FastAPI support (recommended)
pip install "lightning-toll[fastapi]"
# For development
pip install "lightning-toll[dev]"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."}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 automaticallyClient 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: "..." } |
| <───────────────────────────────── |
- Client requests an endpoint without payment
- Server returns 402 Payment Required with a Lightning invoice and a macaroon
- Client pays the invoice with any Lightning wallet
- Client retries with
Authorization: L402 <macaroon>:<preimage> - Server verifies the preimage matches the payment hash, checks the macaroon, and grants access
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"),
)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": "..."}| 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) |
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": "..."}@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: Requestas a parameter. Payment info is injected aspaymentif the parameter exists.
@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
}
]
}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()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()| 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 |
lightning-toll uses Nostr Wallet Connect (NWC) to create invoices and process payments. You need an NWC-compatible Lightning wallet:
- Sign up at getalby.com
- Go to Settings → Wallet Connections → Add Connection
- Copy the NWC URL (starts with
nostr+walletconnect://)
- LNbits with NWC extension
- Mutiny Wallet
- Any wallet implementing NIP-47
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()Macaroons are bearer credentials with embedded restrictions (caveats). lightning-toll uses HMAC-SHA256 chained signatures, identical to the Node.js version.
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).
| 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 |
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)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>"
}
}
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.
- 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=Trueto tie macaroons to client IPs. Beware of NAT/proxies.
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.pyOpen 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| 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 |
MIT — Jeletor