L402-Requests
Three lines of Python. Paid APIs just work.
import l402_requests
response = l402_requests.get("https://api.example.com/paid-resource")
print(response.json())
That's the entire integration. No payment logic. No invoice parsing. No retry code. No protocol knowledge required.
Behind the scenes, L402-Requests detects the 402 challenge, pays the Lightning invoice from your wallet, caches the credential, and retries the request. You get back a normal httpx.Response. The API just worked — and it got paid.
Install
pip install l402-requests
Set one environment variable for your wallet and you're done:
export STRIKE_API_KEY="your-strike-api-key"
That's it. Every L402-protected API you call will automatically get paid.
How It Works
You never see any of this — it happens automatically:
Your Code L402-Requests L402 Server Lightning
──────── ───────────── ─────────── ─────────
│ │ │ │
│──── GET /resource ──────▶│ │ │
│ │──── GET /resource ──────▶│ │
│ │◀── 402 + invoice + mac ──│ │
│ │ │ │
│ │ check budget │ │
│ │ extract amount │ │
│ │ │ │
│ │──── pay invoice ────────────────────────────── ▶│
│ │◀─── preimage ───────────────────────────────── │
│ │ │ │
│ │── GET /resource ────────▶│ │
│ │ Authorization: L402 │ │
│ │◀──── 200 + data ────────│ │
│◀── 200 + data ──────────│ │ │
- You make an HTTP request —
l402_requests.get(url) - If the server returns 200, the response comes back as-is
- If the server returns 402 with an L402 challenge:
- The invoice is parsed automatically
- The amount is checked against your budget
- The invoice is paid via your Lightning wallet
- The request is retried with
Authorization: L402 {macaroon}:{preimage}
- Credentials are cached — subsequent requests to the same endpoint don't re-pay
Wallet Configuration
Set environment variables for your wallet. The library auto-detects in priority order:
| Priority | Wallet | Environment Variables | Preimage | Notes |
|---|---|---|---|---|
| 1 | LND | LND_REST_HOST + LND_MACAROON_HEX | Yes | Requires running a node |
| 2 | NWC | NWC_CONNECTION_STRING | Yes | CoinOS, CLINK compatible |
| 3 | Strike | STRIKE_API_KEY | Yes | No infrastructure required |
| 4 | OpenNode | OPENNODE_API_KEY | Limited | No preimage support |
Strike has full preimage support and requires no infrastructure. Set STRIKE_API_KEY and you're done.
Strike
export STRIKE_API_KEY="your-strike-api-key"
LND
export LND_REST_HOST="https://localhost:8080"
export LND_MACAROON_HEX="your-admin-macaroon-hex"
export LND_TLS_CERT_PATH="/path/to/tls.cert" # optional
NWC (Nostr Wallet Connect)
pip install l402-requests[nwc]
export NWC_CONNECTION_STRING="nostr+walletconnect://pubkey?relay=wss://relay&secret=hex"
OpenNode
export OPENNODE_API_KEY="your-opennode-key"
OpenNode does not return payment preimages, which means L402 credential construction will fail. For L402 use cases, use Strike, LND, or a compatible NWC wallet.
L402 Wallet Compatibility
L402 requires the payment preimage (proof of payment) to construct credentials. Not all wallets return it. If yours doesn't, payment succeeds but API access fails.
| Wallet | Returns Preimage | L402 Works | Notes |
|---|---|---|---|
| LND | ✅ Always | ✅ Yes | Requires running a node |
| NWC (CoinOS) | ✅ Yes | ✅ Yes | Free, easy setup |
| NWC (CLINK) | ✅ Yes | ✅ Yes | Nostr users |
| Strike | ✅ Yes | ✅ Yes | Easy setup, no infrastructure |
| Alby Hub | ✅ Yes | ✅ Yes | Self-custody, NWC compatible |
| Primal | ❌ No | ❌ No | Direct payments only |
| OpenNode | ❌ No | ❌ No | Direct payments only |
For detailed wallet setup instructions, see the MCP Wallet Setup Guide.
Explicit Wallet
You can also pass a wallet directly instead of relying on auto-detection:
from l402_requests import L402Client, StrikeWallet
client = L402Client(
wallet=StrikeWallet(api_key="your-key"),
)
response = client.get("https://api.example.com/paid-resource")
Budget Controls
Safety is built in. Budgets are enabled by default so you can't accidentally overspend:
from l402_requests import L402Client, BudgetController
client = L402Client(
budget=BudgetController(
max_sats_per_request=500, # Max per single payment (default: 1,000)
max_sats_per_hour=5000, # Hourly rolling limit (default: 10,000)
max_sats_per_day=25000, # Daily rolling limit (default: 50,000)
allowed_domains={"api.example.com"}, # Optional domain allowlist
)
)
If a payment would exceed any limit, BudgetExceededError is raised before the payment is attempted — no sats leave your wallet.
To disable budgets entirely:
client = L402Client(budget=None) # Not recommended
Default Limits
| Limit | Default | Description |
|---|---|---|
max_sats_per_request | 1,000 sats | Rejects any single invoice above this |
max_sats_per_hour | 10,000 sats | Rolling 1-hour window |
max_sats_per_day | 50,000 sats | Rolling 24-hour window |
Domain Allowlist
Restrict payments to specific domains:
budget = BudgetController(
allowed_domains={"api.example.com", "store.lightningenable.com"}
)
Any request to a domain not in the list will raise DomainNotAllowedError before attempting payment.
Async Support
Full async support via AsyncL402Client:
from l402_requests import AsyncL402Client
async with AsyncL402Client() as client:
response = await client.get("https://api.example.com/paid-resource")
print(response.json())
Spending Introspection
Track every payment made during a session:
from l402_requests import L402Client
client = L402Client()
client.get("https://api.example.com/data")
client.get("https://api.example.com/more-data")
# Inspect spending
print(f"Total: {client.spending_log.total_spent()} sats")
print(f"Last hour: {client.spending_log.spent_last_hour()} sats")
print(f"Today: {client.spending_log.spent_today()} sats")
print(f"By domain: {client.spending_log.by_domain()}")
# Export as JSON for auditing
print(client.spending_log.to_json())
Credential Caching
L402 credentials are cached by (domain, path_prefix) so you don't re-pay for the same endpoint within a session. The cache uses an LRU eviction strategy with a default TTL of 1 hour.
from l402_requests import L402Client, CredentialCache
client = L402Client(
credential_cache=CredentialCache(
max_size=256, # Maximum cached credentials
default_ttl=3600.0, # 1 hour TTL
)
)
Error Handling
from l402_requests import L402Client, BudgetExceededError, PaymentFailedError, NoWalletError
client = L402Client()
try:
response = client.get("https://api.example.com/paid-resource")
except BudgetExceededError as e:
print(f"Over budget: {e.limit_type} limit is {e.limit_sats} sats")
except PaymentFailedError as e:
print(f"Payment failed: {e.reason}")
except NoWalletError:
print("No wallet configured — set STRIKE_API_KEY or other wallet env vars")
| Exception | When |
|---|---|
BudgetExceededError | Payment would exceed a budget limit |
PaymentFailedError | Lightning payment failed (routing, timeout, etc.) |
InvoiceExpiredError | Invoice expired before payment |
NoWalletError | No wallet env vars detected |
DomainNotAllowedError | Domain not in allowed_domains |
ChallengeParseError | Malformed L402 challenge header |
Example: Lightning Enable Store
Access the Lightning Enable Store — a live L402 commerce demo.
Store products cost 25,000 - 45,000+ sats (including shipping). The default budget limit of 1,000 sats per request will reject these payments. You must increase max_sats_per_request before purchasing.
The Lightning Enable Store currently ships to US addresses only.
The store uses a two-step L402 flow designed for physical goods commerce. Checkout creates the order and returns a 402 with an invoice. After payment, you claim the order at a separate /claim endpoint with the L402 credential. This is intentional — it separates payment from fulfillment, and the claim URL can be shared with a gift recipient to enter their own shipping address.
import json
import httpx
from l402_requests import L402Client, BudgetController
from l402_requests.challenge import parse_challenge
from l402_requests.bolt11 import extract_amount_sats
# Step 1: Browse catalog (free, no payment)
client = L402Client(
budget=BudgetController(max_sats_per_request=50000),
)
catalog = client.get("https://store.lightningenable.com/api/store/catalog")
for product in catalog.json()["products"]:
print(f"[{product['id']}] {product['name']} — {product['priceSats']} sats")
# Step 2: Checkout (returns 402 — L402-Requests pays the invoice automatically)
checkout = client.post(
"https://store.lightningenable.com/api/store/checkout",
json={"items": [{"productId": 2, "quantity": 1, "size": "L", "color": "Black"}]},
)
# The library paid the invoice, but the store uses a separate /claim endpoint.
# The checkout response (402) contains the macaroon; the preimage is in the spending log.
record = client.spending_log.records[-1]
macaroon = parse_challenge(
checkout.history[0].headers["www-authenticate"] # original 402 response
).macaroon if checkout.history else None
# Step 3: Claim the order (header-only — body can be empty)
claim = httpx.post(
"https://store.lightningenable.com/api/store/claim",
headers={
"Authorization": f"L402 {macaroon}:{record.preimage}",
"Content-Type": "application/json",
},
content="{}",
)
claim_data = claim.json()
print(f"Claim URL: {claim_data['claimUrl']}")
# Step 4: Share the claim URL — recipient enters shipping address there
Store claim details
- The
/claimendpoint accepts the L402 credential in theAuthorizationheader only — the request body can be empty ({}). - After claiming, share the
claimUrlwith the recipient to enter their shipping address. - Claim tokens expire in 7 days.
API Reference
L402Client
L402Client(
wallet: WalletBase | None = None,
budget: BudgetController | None = <default>,
credential_cache: CredentialCache | None = None,
**httpx_kwargs,
)
Methods: .get(), .post(), .put(), .delete(), .patch(), .head(), .options(), .request()
Properties:
.spending_log—SpendingLoginstance for payment history
AsyncL402Client
Same API as L402Client but all methods are async. Use as async context manager:
async with AsyncL402Client() as client:
response = await client.get(url)
BudgetController
BudgetController(
max_sats_per_request: int = 1000,
max_sats_per_hour: int = 10000,
max_sats_per_day: int = 50000,
allowed_domains: set[str] | None = None,
)
Wallet Classes
StrikeWallet(api_key: str)LndWallet(host: str, macaroon_hex: str, tls_cert_path: str | None = None)NwcWallet(connection_string: str, timeout: float = 30.0)OpenNodeWallet(api_key: str)
Also Available
- TypeScript:
l402-requests— same "three lines of code" experience for TypeScript/Node.js - .NET:
L402Requests— same "three lines of code" experience for .NET
Source Code
GitHub Repository (MIT License)