Skip to main content

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 ──────────│ │ │
  1. You make an HTTP request — l402_requests.get(url)
  2. If the server returns 200, the response comes back as-is
  3. 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}
  4. 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:

PriorityWalletEnvironment VariablesPreimageNotes
1LNDLND_REST_HOST + LND_MACAROON_HEXYesRequires running a node
2NWCNWC_CONNECTION_STRINGYesCoinOS, CLINK compatible
3StrikeSTRIKE_API_KEYYesNo infrastructure required
4OpenNodeOPENNODE_API_KEYLimitedNo preimage support
Recommended: Strike

Strike has full preimage support and requires no infrastructure. Set STRIKE_API_KEY and you're done.

Credential resolution & priority override

If an environment variable is empty or a ${...} placeholder, the client falls back to ~/.lightning-enable/config.json (shared with the Lightning Enable MCP server), and a wallets.priority key in that file can reorder detection. To switch wallets, clear the old credential in both places — otherwise the config-file fallback silently re-enables it.

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)​

NWC support ships in the base install (since 0.3.0 — the old [nwc] extra is an empty no-op kept for compatibility):

export NWC_CONNECTION_STRING="nostr+walletconnect://pubkey?relay=wss://relay&secret=hex"

OpenNode​

export OPENNODE_API_KEY="your-opennode-key"
OpenNode L402 Limitation

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.

WalletReturns PreimageL402 WorksNotes
LND✅ Always✅ YesRequires running a node
NWC (CoinOS)✅ Yes✅ YesFree, easy setup
NWC (CLINK)✅ Yes✅ YesNostr users
Strike✅ Yes✅ YesEasy setup, no infrastructure
Alby Hub✅ Yes✅ YesRun your own hub, NWC compatible
Primal❌ No❌ NoDirect payments only
OpenNode❌ No❌ NoDirect 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​

LimitDefaultDescription
max_sats_per_request1,000 satsRejects any single invoice above this
max_sats_per_hour10,000 satsRolling 1-hour window
max_sats_per_day50,000 satsRolling 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")
ExceptionWhen
BudgetExceededErrorPayment would exceed a budget limit
PaymentFailedErrorLightning payment failed (routing, timeout, etc.)
InvoiceExpiredErrorInvoice expired before payment
NoWalletErrorNo wallet env vars detected
DomainNotAllowedErrorDomain not in allowed_domains
ChallengeParseErrorMalformed L402 challenge header

Example: Lightning Enable Store​

Access the Lightning Enable Store — a live L402 commerce demo.

Budget Configuration Required

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.

US Shipping Only

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
import httpx

from l402_requests import L402Client, BudgetController
from l402_requests.challenge import parse_challenge
from l402_requests.wallets import auto_detect_wallet

# 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 WITHOUT auto-pay — capture the 402 challenge yourself,
# because the separate /claim call needs the macaroon from this exact challenge
checkout = httpx.post(
"https://store.lightningenable.com/api/store/checkout",
json={"items": [{"productId": 2, "quantity": 1, "size": "L", "color": "Black"}]},
)
assert checkout.status_code == 402
challenge = parse_challenge(checkout.headers["www-authenticate"])

# Step 3: Pay the invoice with your configured wallet — returns the preimage
wallet = auto_detect_wallet()
preimage = wallet.pay_invoice_sync(challenge.invoice)

# Step 4: Claim the order (header-only — body can be empty)
claim = httpx.post(
"https://store.lightningenable.com/api/store/claim",
headers={
"Authorization": f"L402 {challenge.macaroon}:{preimage}",
"Content-Type": "application/json",
},
content="{}",
)
print(f"Claim URL: {claim.json()['claimUrl']}")

# Step 5: Share the claim URL — recipient enters shipping address there
Why capture the 402 manually?

Auto-paid requests (client.post(...)) don't currently expose the challenge macaroon afterward, and paying the wallet directly bypasses the client's budget checks — so for two-step purchase flows, capture the challenge first as shown and check the amount before paying. The next minor release adds macaroon to the spending-log PaymentRecord, after which the auto-pay flow can claim directly.

Store claim details​

  • The /claim endpoint accepts the L402 credential in the Authorization header only — the request body can be empty ({}).
  • After claiming, share the claimUrl with 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 — SpendingLog instance 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)