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: "..." } |
| <───────────────────────────────── |
- 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
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: Requestas a parameter. Payment info is injected aspaymentif 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
- Sign up at getalby.com
- Go to Settings → Wallet Connections → Add Connection
- 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=Trueto 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file lightning_toll-0.1.0.tar.gz.
File metadata
- Download URL: lightning_toll-0.1.0.tar.gz
- Upload date:
- Size: 30.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.9.23
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
82ce5d92c9e3cb89567a4d8af632acbe135677f16094b1f32822a6006c5b274f
|
|
| MD5 |
6652c5055a5aee8ddb805a28f792d45b
|
|
| BLAKE2b-256 |
d34ac4494521fe6ef6f23c5e0f53a4ffa3ed4a4c62592bebe9ba27c065e5fca4
|
File details
Details for the file lightning_toll-0.1.0-py3-none-any.whl.
File metadata
- Download URL: lightning_toll-0.1.0-py3-none-any.whl
- Upload date:
- Size: 25.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.9.23
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2ec3aecfc049d0b440e051cc3e5b546035f178d32333b46816398b7aa208b155
|
|
| MD5 |
78cba2c65c4dd65aade839c37d4d6813
|
|
| BLAKE2b-256 |
b6839dc315203006d801c93f33277f8b0438039832cd321f8d3767bbf2c7060f
|