Skip to main content

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

HeaderRequiredDescription
AuthorizationNoL402 <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>
ComponentFormatDescription
macaroonBase64Bearer token with caveats
preimageHex (64 chars)32-byte proof of payment

Verification

The server verifies the credential in the following order:

  1. Preimage matches hash: SHA256(preimage) == payment_hash
  2. Macaroon signature: HMAC-SHA256 verification ensures the token was not tampered with
  3. 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)

tip

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.

CaveatExampleDescription
servicesservices = lightning-enable:0Service identifier and tier.
pathpath = /l402/proxy/my-api/dataBinds 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_idmerchant_id = 42Binds 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_idcharge_id = abc123-def456The OpenNode charge ID associated with the payment.
amount_satsamount_sats = 100Binds 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.
expiresexpires = 1704067200Unix 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:

  1. Preimage verification -- SHA256(preimage) == payment_hash (proves payment was made)
  2. Macaroon signature -- HMAC-SHA256 verification (proves the token was not tampered with)
  3. Caveat satisfaction -- each caveat is evaluated against the current request context:
    • expires: the current time must be before the expiration timestamp
    • path: the request path must match the bound path (exact or wildcard prefix)
    • merchant_id: the request's merchant context must match the bound merchant ID
    • amount_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