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:

  1. Preimage matches hash: SHA256(preimage) == payment_hash
  2. Macaroon signature: Token wasn't tampered with
  3. Caveats satisfied: Token not expired, correct path, etc.

Token Reuse

L402 tokens can be reused until they expire:

// Save credential after first payment
const credential = `${macaroon}:${preimage}`;
localStorage.setItem('l402_credential', credential);

// Reuse for subsequent requests
const savedCredential = localStorage.getItem('l402_credential');
fetch(url, {
headers: { 'Authorization': `L402 ${savedCredential}` }
});

Default token validity: 1 hour (configurable per endpoint)

Macaroon Structure

Macaroons contain:

{
"identifier": "lightning-enable:payment_hash:timestamp",
"caveats": [
"services = lightning-enable:0",
"path = /l402/proxy/my-api/*",
"expires = 1704067200"
],
"signature": "hmac-sha256"
}

Caveat Types

CaveatDescription
servicesService identifier and tier
pathAllowed request paths
expiresExpiration timestamp
ipClient IP restriction (optional)

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