Skip to main content

⚡ 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

Metadata

Release files for lightning-toll 0.1.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for lightning-toll 0.1.1
File Size Uploaded
lightning_toll-0.1.1.tar.gz 30.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for lightning-toll 0.1.1
File Interpreter ABI Platform
lightning_toll-0.1.1-py3-none-any.whl Python 3 none any Details

Total release size: 57.1 kB

Release files / lightning_toll-0.1.1.tar.gz

Download URL lightning_toll-0.1.1.tar.gz
Size 30.8 kB
Tags Source
SHA-256 checksum
How to use checksums
f29d88329741206421a098184c02f44f0fd970998bb3e2c3148680a1d0eba5a4
BLAKE2b-256 checksum
How to use checksums
097f68ffc1c992bb054a98fe83f40edda5e33b1d7e05f51778e4e43b562e57ce
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.9.23

Release files / lightning_toll-0.1.1-py3-none-any.whl

Download URL lightning_toll-0.1.1-py3-none-any.whl
Size 26.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
fe20ac0aa405752c9a97f6bb0ff95c1746c9ab4b310ed7320a37671c13fb7319
BLAKE2b-256 checksum
How to use checksums
97056b5c66163f934040d5ae0d062571e2bec95842a2b7c22af6da5e0dd3ea3c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.9.23

Release history Release notifications | RSS feed

This release

0.1.1 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page