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

Lightning Enable speaks two schemes on the same 402: classic L402 (macaroon + preimage) and the IETF Payment scheme (MPP, Machine Payments Protocol). One invoice backs both, so any client can pick the one it implements. See Payment (MPP) credentials.

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"
}

Payment (MPP) credentials​

Alongside L402, every 402 also advertises the HTTP Payment authentication scheme, the format shared by draft-httpauth-payment-00 (the core scheme) and draft-lightning-charge-00 (the Lightning charge method). This is usually called MPP (Machine Payments Protocol). Three credential profiles are accepted, and all three are proofs of the same invoice:

ProfileAuthorization headerReusable?Best for
Classic L402L402 <macaroon>:<preimage>Yes, until the macaroon expiresExisting L402 clients, LND lnget, Aperture-style tooling
Legacy Payment (auth-params)Payment method="lightning", preimage="<hex>"Yes, until the challenge expiresClients that only want to send a preimage
Modern Payment (bearer token)Payment <base64url(JSON)>No. Single useClients built on the current drafts (mppx, l402-requests, the Lightning Enable MCP server v1.24+)

You never opt in per merchant. The server emits both schemes for every L402-gated resource, and a client uses whichever one it implements.

The 402 challenge​

A single 402 carries one WWW-Authenticate header per scheme. The Payment header is a superset: it holds the modern parameters and the legacy invoice / amount / currency parameters in the same value, and both drafts require clients to ignore parameters they don't know.

HTTP/1.1 402 Payment Required
WWW-Authenticate: L402 macaroon="AgEL...", invoice="lnbc1u1p..."
WWW-Authenticate: Payment id="k9Q3...", realm="lightning-enable", method="lightning", intent="charge", request="eyJhbW91bnQiOiIxMDAiLC...", expires="2026-09-11T18:30:00Z", invoice="lnbc1u1p...", amount="100", currency="sat"
Cache-Control: no-store
ParameterMeaning
idServer-computed binding over every other parameter. Verification recomputes it from the fields you echo back, so any edit to the challenge invalidates the credential.
realmAlways lightning-enable on the hosted API.
method / intentAlways lightning / charge.
requestbase64url of a JCS-canonical JSON object: {"amount":"100","currency":"sat","methodDetails":{"invoice":"lnbc...","network":"mainnet","paymentHash":"<hex>"}}. Echo it byte for byte.
expiresRFC 3339 UTC. Never later than the BOLT11 invoice expiry. A modern credential must be redeemed before this instant.
digestPresent only on proxied POST / PUT / PATCH requests with a body: the RFC 9530 Content-Digest of that body, bound into id. Resend the identical body when you redeem.
invoice, amount, currencyLegacy parameters for clients that predate the drafts. Same invoice as request.methodDetails.invoice.

Send Accept-Payment: lightning/charge;q=0 on the initial request if you want the Payment header suppressed and only the L402 header returned.

Redeem with a modern credential​

Pay the invoice, then build the credential the drafts describe: a JSON object with the challenge you received (echoed exactly, including request) and a payload holding the preimage as 64 lowercase hex characters. base64url-encode it and send it as a bearer token.

{
"challenge": {
"id": "k9Q3...",
"realm": "lightning-enable",
"method": "lightning",
"intent": "charge",
"request": "eyJhbW91bnQiOiIxMDAiLC...",
"expires": "2026-09-11T18:30:00Z"
},
"payload": { "preimage": "7f8a9b2c...e9f0a" }
}
GET /l402/proxy/abc123/api/data HTTP/1.1
Authorization: Payment eyJjaGFsbGVuZ2UiOnsiaWQiOiJrOVEz...

On success the response includes a receipt you can store or forward:

HTTP/1.1 200 OK
Payment-Receipt: eyJjaGFsbGVuZ2VJZCI6Ims5UTMuLi4iLCJtZXRob2QiOiJsaWdodG5pbmciLC...
Cache-Control: private

The receipt decodes to {"challengeId":"k9Q3...","method":"lightning","reference":"<payment hash>","status":"success","timestamp":"2026-09-11T18:12:04Z"}. The reference is the payment hash, never the preimage.

Single use. A modern credential is consumed atomically the first time it verifies. A second request with the same token gets a fresh 402. If the server accepted the credential but failed to deliver the upstream response, the consumption is released so you can retry with the same token. Redeem before expires. Classic L402 and legacy Payment credentials keep their reuse-until-expiry behaviour; nothing about them changed.

Redeem with a legacy Payment credential​

Authorization: Payment method="lightning", preimage="7f8a9b2c...e9f0a"

No macaroon and no challenge echo. The server matches the preimage to a challenge it minted for that resource and price. Reusable until the challenge expires.

Modern-path errors​

Failures on the modern path return 402 with application/problem+json, a fresh challenge in WWW-Authenticate, and Cache-Control: no-store. The type is https://paymentauth.org/problems/<slug>:

type slugMeaning
invalid-challengeThe echoed challenge doesn't decode, has a currency other than sat, or is missing its expiry or payment hash.
verification-failedThe preimage doesn't hash to the challenge's payment hash, the challenge belongs to another merchant, the credential is past expires, or it was already redeemed (single use). The detail member says which.
payment-insufficientThe challenge amount doesn't match the resource's price.
method-unsupportedModern credentials are switched off on this server. Fall back to L402.

A malformed bearer token is rejected outright. It is never reinterpreted as a legacy credential.

Verify credentials as a producer​

If you mint challenges with the Producer API, verify each profile with the matching endpoint:


L402 Producer API​

The Producer API lets merchants create L402 challenges programmatically — enabling agent-to-agent commerce where your AI agent charges other agents for access to resources.

Requires Agentic Commerce Subscription

The Producer API requires a Lightning Enable subscription — Agentic Commerce ($49/mo) or Agentic Commerce — Business (contact us). Consumer tools (paying for APIs, accessing L402 resources) are free — no subscription needed.

Agents That Earn

This is the supply side of agentic commerce. Your agent creates L402 challenges; other agents pay them. See the L402 Producer API guide for the full walkthrough.

Create L402 Challenge​

Create a Lightning invoice + macaroon challenge for a resource.

POST /api/l402/challenges

Headers​

HeaderRequiredDescription
X-API-KeyYesMerchant API key
Idempotency-KeyNoRetry-safe key, max 200 characters. The same key with the same resource and price returns the same challenge for the life of that invoice; a different resource or price under the same key is a 409 while that invoice is still live, and a fresh mint once it has expired. See Idempotency.
X-Idempotency-KeyNoThe spelling this API shipped with. Identical behaviour; Idempotency-Key wins if you send both.

Request Body​

FieldTypeRequiredDescription
resourcestringYesResource identifier (URL, service name, or description)
priceSatslongYesPrice in satoshis (minimum 1)
descriptionstringNoDescription shown on the Lightning invoice
idempotencyKeystringNoSame meaning as the Idempotency-Key header, for clients that can't set headers

Example​

curl -X POST https://api.lightningenable.com/api/l402/challenges \
-H "X-API-Key: YOUR_MERCHANT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"resource": "/api/weather/forecast",
"priceSats": 50,
"description": "7-day weather forecast"
}'

Response (200 OK)​

{
"invoice": "lnbc500n1p3xyza...",
"macaroon": "AgELbGlnaHRuaW5n...",
"paymentHash": "abc123def456...",
"expiresAt": "2026-03-13T14:30:00Z",
"resource": "/api/weather/forecast",
"priceSats": 50
}

Error Responses​

Returned as application/problem+json with a stable type URI. See Error format for the full body and the complete code list.

StatusDescription
400Missing required field, invalid price, blank or over-long idempotency key, or no payment provider key on your account
401Missing or invalid API key
402A plan cap was reached (price, monthly volume, or distinct endpoints)
403L402 not enabled on your plan
409The idempotency key was already used for a different resource or price, or the endpoint was retired
503The challenge could not be durably recorded, so none was issued — nothing was invoiced

List Your Challenges​

List the challenges you have minted, with payment status. Scoped to the account behind your API key.

GET /api/l402/challenges?status=paid&since=2026-09-01T00:00:00Z&limit=50&offset=0
Query parameterDefaultDescription
statusnonepaid, unpaid, or expired
sincenoneISO 8601 lower bound on createdAt
limit50Clamped to 1..200
offset0Clamped to at least 0

Returns { challenges, total, limit, offset, status, since }; the unpaged total is also in the X-Total-Count header. Each challenge carries paymentHash, resource, amountSats, status, createdAt, paidAt, expiresAt, and idempotencyKey — never a macaroon or a preimage.

curl "https://api.lightningenable.com/api/l402/challenges?status=paid&limit=25" \
-H "X-API-Key: YOUR_MERCHANT_API_KEY"

Get One Challenge​

GET /api/l402/challenges/{paymentHash}

Returns the same object as one element of the list. A payment hash belonging to another account returns 404, not 403.


Verify L402 Token​

Verify an L402 token (macaroon + preimage) to confirm payment.

POST /api/l402/challenges/verify

Headers​

HeaderRequiredDescription
X-API-KeyYesMerchant API key

Request Body​

FieldTypeRequiredDescription
macaroonstringYesBase64-encoded macaroon
preimagestringYesHex-encoded preimage (64 characters)

Example​

curl -X POST https://api.lightningenable.com/api/l402/challenges/verify \
-H "X-API-Key: YOUR_MERCHANT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"macaroon": "AgELbGlnaHRuaW5n...",
"preimage": "7f8a9b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a"
}'

Response (200 OK — valid)​

{
"valid": true,
"resource": "/api/weather/forecast",
"merchantId": 42,
"amountSats": 50,
"paymentHash": "abc123def456..."
}

Response (200 OK — invalid)​

{
"valid": false,
"error": "Preimage does not match payment hash"
}
Verifying marks the challenge paid

The first successful verification of a credential records the underlying challenge as paid — it starts showing as status: "paid" in the listing above, and fires the l402.challenge.paid webhook to your callback URL if you have one configured. Later verifications of the same credential do not re-fire it. See Payment webhooks.


Verify Payment Credential (single-use)​

Verify a modern Payment bearer credential (the base64url JSON token described in Payment (MPP) credentials). Verifying consumes the credential: a second call with the same token returns valid: false.

POST /api/l402/challenges/verify-credential

Headers​

HeaderRequiredDescription
X-API-KeyYesMerchant API key

Request Body​

FieldTypeRequiredDescription
credentialstringYesThe token exactly as received, with or without the leading Payment scheme word
resourcestringNoReject unless the challenge was minted for this resource
amountSatsintegerNoReject unless the challenge was minted for this price

Example​

curl -X POST https://api.lightningenable.com/api/l402/challenges/verify-credential \
-H "X-API-Key: YOUR_MERCHANT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"credential": "<the base64url token from the Authorization header>",
"resource": "/api/weather/forecast",
"amountSats": 50
}'

Response (200 OK — valid)​

{
"valid": true,
"consumed": true,
"resource": "/api/weather/forecast",
"merchantId": 42,
"amountSats": 50,
"paymentHash": "abc123def456...",
"receipt": "eyJjaGFsbGVuZ2VJZCI6Ims5UTMuLi4iLC..."
}

Serve receipt back to the payer as the Payment-Receipt response header on the resource you gate.

Response (200 OK — invalid)​

{
"valid": false,
"consumed": false,
"error": "Preimage does not match the challenge payment hash."
}

400 mpp_not_supported means modern credentials are switched off on the server. Like /verify, the first successful call marks the challenge paid and fires l402.challenge.paid.


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​