L402 API
L402 (formerly LSAT) enables pay-per-request API access using Lightning Network payments.
Overview
L402 is a protocol for HTTP 402 Payment Required responses that enables:
- Pay-per-request API access without subscriptions
- Anonymous access - no accounts or credit cards needed
- Instant micropayments via Lightning Network
- Cryptographic verification using macaroons and preimages
Endpoints
Get L402 Pricing
Get pricing information for L402-protected endpoints.
GET /api/l402/pricing
Response
{
"defaultPriceSats": 10,
"serviceName": "lightning-enable",
"endpoints": [
{
"pathPattern": "/api/premium/*",
"priceSats": 100,
"description": "Premium API access"
},
{
"pathPattern": "/l402/proxy/*",
"priceSats": "varies",
"description": "Proxy pricing set per-proxy"
}
],
"tokenValiditySeconds": 3600
}
Check L402 Status
Check if a request has valid L402 authentication.
GET /api/l402/status
Headers
| Header | Required | Description |
|---|---|---|
Authorization | No | L402 <macaroon>:<preimage> |
Response (Authenticated)
{
"authenticated": true,
"paymentHash": "abc123...",
"expiresAt": "2024-12-29T13:00:00Z",
"remainingRequests": null
}
Response (Not Authenticated)
{
"authenticated": false,
"message": "L402 credential required"
}
L402 Protected Proxy
Access proxied APIs with L402 payment.
* /l402/proxy/{proxyId}/{path}
Without L402 Credential
Returns 402 Payment Required:
{
"error": "Payment Required",
"message": "Pay the Lightning invoice to access this resource",
"l402": {
"macaroon": "AgELbGlnaHRuaW5nLWVuYWJsZQJCMDAwMDAwMD...",
"invoice": "lnbc100n1pnxyz...",
"amount_sats": 10,
"payment_hash": "abc123def456...",
"expires_at": "2024-12-29T13:00:00Z"
}
}
With Valid L402 Credential
curl https://api.lightningenable.com/l402/proxy/{proxyId}/endpoint \
-H "Authorization: L402 AgEL...:abc123..."
Returns the proxied API response.
L402 Authentication Flow
Step 1: Request Protected Resource
curl https://api.lightningenable.com/l402/proxy/my-api/data
Step 2: Receive 402 Challenge
HTTP/1.1 402 Payment Required
WWW-Authenticate: L402 macaroon="AgEL...", invoice="lnbc..."
{
"error": "Payment Required",
"l402": {
"macaroon": "AgEL...",
"invoice": "lnbc100n1p...",
"amount_sats": 10,
"payment_hash": "abc123..."
}
}
Step 3: Pay Lightning Invoice
Pay the invoice using any Lightning wallet. You'll receive a preimage (proof of payment).
Step 4: Access with Credential
Combine macaroon and preimage:
curl https://api.lightningenable.com/l402/proxy/my-api/data \
-H "Authorization: L402 AgEL...:abc123def456789..."
Step 5: Receive Response
HTTP/1.1 200 OK
{
"data": "Your requested content"
}
Credential Format
The L402 credential consists of two parts:
Authorization: L402 <macaroon>:<preimage>
| Component | Format | Description |
|---|---|---|
macaroon | Base64 | Bearer token with caveats |
preimage | Hex (64 chars) | 32-byte proof of payment |
Verification
The server verifies the credential in the following order:
- Preimage matches hash:
SHA256(preimage) == payment_hash - Macaroon signature: HMAC-SHA256 verification ensures the token was not tampered with
- All caveats satisfied:
expires(not expired),path(matches request path),merchant_id(matches request merchant),amount_sats(matches endpoint price)
Token Reuse
L402 tokens can be reused until they expire, but only for the same endpoint, merchant, and price tier they were issued for. The path, merchant_id, and amount_sats caveats are checked on every request, so a token cannot be reused across different contexts.
// Save credential after first payment
const credential = `${macaroon}:${preimage}`;
localStorage.setItem('l402_credential', credential);
// Reuse for subsequent requests to the SAME endpoint
const savedCredential = localStorage.getItem('l402_credential');
fetch(url, {
headers: { 'Authorization': `L402 ${savedCredential}` }
});
Default token validity: 1 hour (configurable per endpoint)
When caching credentials, key them by the full endpoint path (not just the host) since tokens are path-bound. A token issued for /l402/proxy/api-a/data will be rejected if used against /l402/proxy/api-b/data.
Macaroon Structure
Macaroons are cryptographic bearer tokens signed with HMAC-SHA256. Each macaroon contains an identifier, a set of caveats, and a signature. Lightning Enable embeds security caveats at issuance time that bind the token to a specific context, preventing reuse across endpoints, merchants, or price tiers.
{
"identifier": "lightning-enable:payment_hash:timestamp",
"caveats": [
"services = lightning-enable:0",
"path = /l402/proxy/my-api/data",
"merchant_id = 42",
"charge_id = abc123-def456",
"amount_sats = 100",
"expires = 1704067200"
],
"signature": "hmac-sha256"
}
Caveat Types
Every macaroon issued by Lightning Enable includes the following caveats. During verification, all caveats must be satisfied for the token to be accepted. An unknown or unsatisfied caveat causes verification to fail.
| Caveat | Example | Description |
|---|---|---|
services | services = lightning-enable:0 | Service identifier and tier. |
path | path = /l402/proxy/my-api/data | Binds the token to the API path it was issued for. A token issued for /api/premium/v1 cannot be used against /api/premium/v2. Wildcard paths (e.g., /l402/proxy/my-api/*) allow access to any sub-path under the prefix. |
merchant_id | merchant_id = 42 | Binds the token to the issuing merchant. Prevents cross-tenant token reuse -- a token issued by Merchant A cannot be replayed against Merchant B's endpoints. Both directions are enforced: if the request has a merchant context, the token must contain a matching merchant_id caveat, and vice versa. |
charge_id | charge_id = abc123-def456 | The OpenNode charge ID associated with the payment. |
amount_sats | amount_sats = 100 | Binds the token to the price at issuance. Prevents a token purchased at a lower price (e.g., 10 sats for a demo endpoint) from being reused against a higher-priced endpoint (e.g., 100 sats for premium data) that happens to share a wildcard path pattern. |
expires | expires = 1704067200 | Unix timestamp after which the token is no longer valid. Default validity is 1 hour (configurable per endpoint). |
Caveat Verification
When a client presents an L402 credential, Lightning Enable performs the following checks in order:
- Preimage verification --
SHA256(preimage) == payment_hash(proves payment was made) - Macaroon signature -- HMAC-SHA256 verification (proves the token was not tampered with)
- Caveat satisfaction -- each caveat is evaluated against the current request context:
expires: the current time must be before the expiration timestamppath: the request path must match the bound path (exact or wildcard prefix)merchant_id: the request's merchant context must match the bound merchant IDamount_sats: the endpoint's current price must match the bound amount- Any unrecognized caveat causes the verification to fail (closed-world assumption)
If any check fails, the server returns an appropriate error (401 or 403) with a description of the failure.
Error Responses
402 Payment Required
{
"error": "Payment Required",
"message": "Pay the Lightning invoice to access this resource",
"l402": {
"macaroon": "...",
"invoice": "...",
"amount_sats": 10
}
}
401 Invalid Credential
{
"error": "Unauthorized",
"message": "Invalid L402 credential",
"details": "Preimage does not match payment hash"
}
403 Token Expired
{
"error": "Forbidden",
"message": "L402 token has expired",
"details": "Token expired at 2024-12-29T12:00:00Z"
}
403 Path Not Allowed
{
"error": "Forbidden",
"message": "Token not valid for this path",
"allowed": "/l402/proxy/api-a/*",
"requested": "/l402/proxy/api-b/data"
}
Code Examples
JavaScript L402 Client
class L402Client {
constructor() {
this.credentials = new Map();
}
async request(url, options = {}) {
const credential = this.credentials.get(this.getHost(url));
const headers = {
...options.headers,
...(credential && { 'Authorization': `L402 ${credential}` })
};
const response = await fetch(url, { ...options, headers });
if (response.status === 402) {
return this.handlePaymentRequired(url, response, options);
}
return response;
}
async handlePaymentRequired(url, response, options) {
const { l402 } = await response.json();
// Pay invoice and get preimage
const preimage = await this.payInvoice(l402.invoice);
// Store credential
const credential = `${l402.macaroon}:${preimage}`;
this.credentials.set(this.getHost(url), credential);
// Retry request
return this.request(url, options);
}
async payInvoice(invoice) {
// Integrate with your Lightning wallet
// Return the preimage after payment
throw new Error('Implement payInvoice()');
}
getHost(url) {
return new URL(url).host;
}
}
// Usage
const client = new L402Client();
const response = await client.request('https://api.example.com/l402/proxy/my-api/data');
Python L402 Client
import hashlib
import requests
class L402Client:
def __init__(self, pay_invoice_callback):
self.credentials = {}
self.pay_invoice = pay_invoice_callback
def request(self, url, **kwargs):
credential = self.credentials.get(self._get_host(url))
if credential:
kwargs.setdefault('headers', {})
kwargs['headers']['Authorization'] = f'L402 {credential}'
response = requests.request('GET', url, **kwargs)
if response.status_code == 402:
return self._handle_payment_required(url, response, kwargs)
return response
def _handle_payment_required(self, url, response, kwargs):
data = response.json()
l402 = data['l402']
# Pay invoice
preimage = self.pay_invoice(l402['invoice'])
# Verify preimage matches
payment_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest()
assert payment_hash == l402['payment_hash']
# Store and retry
self.credentials[self._get_host(url)] = f"{l402['macaroon']}:{preimage}"
return self.request(url, **kwargs)
def _get_host(self, url):
from urllib.parse import urlparse
return urlparse(url).netloc
cURL Workflow
#!/bin/bash
# Step 1: Get challenge
RESPONSE=$(curl -s https://api.example.com/l402/proxy/my-api/data)
HTTP_CODE=$(echo "$RESPONSE" | jq -r '.error // empty')
if [ "$HTTP_CODE" == "Payment Required" ]; then
MACAROON=$(echo "$RESPONSE" | jq -r '.l402.macaroon')
INVOICE=$(echo "$RESPONSE" | jq -r '.l402.invoice')
echo "Pay this invoice: $INVOICE"
echo "Enter preimage after payment:"
read PREIMAGE
# Step 2: Access with credential
curl https://api.example.com/l402/proxy/my-api/data \
-H "Authorization: L402 $MACAROON:$PREIMAGE"
fi
Wallet Integration
WebLN (Browser)
async function payL402Invoice(invoice) {
if (!window.webln) {
throw new Error('WebLN not available');
}
await window.webln.enable();
const { preimage } = await window.webln.sendPayment(invoice);
return preimage;
}
LND REST API
async function payWithLND(invoice) {
const response = await fetch(`${LND_REST_URL}/v1/channels/transactions`, {
method: 'POST',
headers: { 'Grpc-Metadata-macaroon': ADMIN_MACAROON },
body: JSON.stringify({ payment_request: invoice })
});
const { payment_preimage } = await response.json();
return Buffer.from(payment_preimage, 'base64').toString('hex');
}
Core Lightning
# Pay and get preimage
lightning-cli pay $INVOICE
PREIMAGE=$(lightning-cli listpays bolt11=$INVOICE | jq -r '.pays[0].preimage')
Best Practices
Store Credentials
Cache L402 credentials for token lifetime:
const CREDENTIAL_KEY = 'l402_credentials';
function saveCredential(host, credential, expiresAt) {
const credentials = JSON.parse(localStorage.getItem(CREDENTIAL_KEY) || '{}');
credentials[host] = { credential, expiresAt };
localStorage.setItem(CREDENTIAL_KEY, JSON.stringify(credentials));
}
function getCredential(host) {
const credentials = JSON.parse(localStorage.getItem(CREDENTIAL_KEY) || '{}');
const data = credentials[host];
if (data && new Date(data.expiresAt) > new Date()) {
return data.credential;
}
return null;
}
Handle Expired Tokens
async function request(url) {
const response = await fetch(url, {
headers: { 'Authorization': `L402 ${getCredential(url)}` }
});
if (response.status === 403) {
// Token expired, clear and get new one
clearCredential(url);
return request(url);
}
return response;
}
Budget Limits
Set spending limits:
class BudgetedL402Client extends L402Client {
constructor(maxSatsPerHour) {
super();
this.maxSats = maxSatsPerHour;
this.spent = 0;
this.resetTime = Date.now() + 3600000;
}
async handlePaymentRequired(url, response, options) {
const { l402 } = await response.json();
if (Date.now() > this.resetTime) {
this.spent = 0;
this.resetTime = Date.now() + 3600000;
}
if (this.spent + l402.amount_sats > this.maxSats) {
throw new Error('Budget exceeded');
}
this.spent += l402.amount_sats;
return super.handlePaymentRequired(url, response, options);
}
}
Next Steps
- Proxy Configuration - Set up L402 proxies
- How It Works - Technical deep dive
- API Monetization - Protect your APIs