L402 starter
Wrap a route you already serve. An unpaid call returns HTTP 402 with an L402 challenge. Amboss Payments creates the Lightning invoice. This starter signs the L402 token. Amboss does not document an L402 macaroon API.
Set up Amboss Payments. Amazap earns a referral credit if you sign up through this link.
Docker
docker build -t l402-seller examples/l402-seller-starter && docker run --rm -p 8787:8787 -e AMBOSS_API_KEY -e AMBOSS_WALLET_ID -e L402_TOKEN_SECRET l402-seller
Model proxy
POST /v1/chat/completions forwards a paid request to an OpenAI-compatible server such as Ollama, llama.cpp server, or vLLM. max_tokens is capped. stream is forced off. An unpaid request, including an empty POST, returns 402 before the body is checked.
Node
/**
* Wrap one Express route with L402.
*
* Amboss Payments issues the BOLT11 invoice. This process signs the L402
* token. Amboss does not document an L402 macaroon API.
*
* Verified against the public docs (October 2026):
* - GraphQL: https://app.amboss.tech/graphql
* - Auth header: x-api-key
* - Invoice: payment.transaction.create_receive
* https://docs.amboss.tech/payments/receive-payments
* - Wallet: payment.wallet.find_one { is_ready asset { symbol precision } }
* https://docs.amboss.tech/payments/prompt-for-agents
*
* TODO for Jesse: the docs show payment_hash as hex ("3b6e7d..."). This
* starter assumes that value is the hex SHA256 of the 32-byte preimage.
* If a live create_receive returns a different encoding, paid retries will
* fail until this check is updated.
*
* Amounts are decimal strings in the asset's minor units. For a BTC wallet
* that is satoshis. Do not hard-code an asset id. Create a BTC wallet in
* the dashboard (or from the live asset list, type BASE_ASSET) and set
* AMBOSS_WALLET_ID.
*/
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import express from 'express';
const GRAPHQL = 'https://app.amboss.tech/graphql';
const PRICE_SATS = Math.max(1, Number(process.env.PRICE_SATS || 21));
const MAX_TOKENS = Math.max(1, Number(process.env.MAX_TOKENS || 256));
const MODEL_UPSTREAM = process.env.MODEL_UPSTREAM || 'http://127.0.0.1:11434/v1/chat/completions';
const PORT = Number(process.env.PORT || 8787);
const ROOT = path.dirname(fileURLToPath(import.meta.url));
if (!process.env.L402_TOKEN_SECRET) {
process.env.L402_TOKEN_SECRET = randomBytes(32).toString('hex');
console.warn('L402_TOKEN_SECRET is unset. Paid retries will fail after a restart. Set a stable secret.');
}
const spent = new Set();
function b64url(buf) {
return Buffer.from(buf).toString('base64url');
}
function signToken({ paymentHash, method, path: resource }) {
const payload = b64url(JSON.stringify({
v: 1,
payment_hash: String(paymentHash || '').toLowerCase(),
method,
path: resource,
exp: Math.floor(Date.now() / 1000) + 3600,
}));
const mac = createHmac('sha256', process.env.L402_TOKEN_SECRET).update(payload).digest();
return `${payload}.${b64url(mac)}`;
}
function readToken(token) {
const [payload, mac] = String(token || '').split('.');
if (!payload || !mac) return null;
const expected = createHmac('sha256', process.env.L402_TOKEN_SECRET).update(payload).digest();
const got = Buffer.from(mac, 'base64url');
if (got.length !== expected.length || !timingSafeEqual(got, expected)) return null;
try {
const body = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
if (!body || body.v !== 1 || !body.payment_hash) return null;
if (Number(body.exp) < Math.floor(Date.now() / 1000)) return null;
return body;
} catch {
return null;
}
}
function preimageHash(preimage) {
const text = String(preimage || '').trim();
const bytes = /^[0-9a-f]{64}$/i.test(text) ? Buffer.from(text, 'hex') : Buffer.from(text, 'base64');
if (bytes.length !== 32) return null;
return createHash('sha256').update(bytes).digest('hex');
}
async function amboss(query, variables) {
const res = await fetch(GRAPHQL, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': process.env.AMBOSS_API_KEY || '',
},
body: JSON.stringify({ query, variables }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok || body.errors?.length) {
const message = body.errors?.map((item) => item.message).filter(Boolean).join('; ');
throw new Error(message || `Amboss Payments returned HTTP ${res.status}`);
}
return body.data;
}
async function assertBtcWallet(walletId) {
const data = await amboss(
`query Wallet($id: String!) {
payment { wallet { find_one(id: $id) { id is_ready asset { symbol precision } } } }
}`,
{ id: walletId }
);
const wallet = data?.payment?.wallet?.find_one;
if (!wallet) throw new Error('Amboss Payments did not return that wallet.');
if (!wallet.is_ready) {
throw new Error('The Amboss wallet is not ready yet. A live wallet can take a while to provision liquidity.');
}
if (wallet.asset?.symbol && wallet.asset.symbol !== 'BTC') {
throw new Error('This starter invoices in sats. Point AMBOSS_WALLET_ID at a BTC wallet.');
}
return wallet;
}
async function createInvoice(resource) {
const walletId = process.env.AMBOSS_WALLET_ID || '';
if (!process.env.AMBOSS_API_KEY || !walletId) {
throw new Error('Set AMBOSS_API_KEY and AMBOSS_WALLET_ID.');
}
await assertBtcWallet(walletId);
const data = await amboss(
`mutation CreateReceive($input: CreateReceiveTransactionInput!) {
payment {
transaction {
create_receive(input: $input) {
id
status
payment_request
payment_hash
expires_at
}
}
}
}`,
{
input: {
wallet_id: walletId,
amount: String(PRICE_SATS),
description: `API call ${resource}`,
expires_in_seconds: 3600,
idempotency_key: `l402-${randomBytes(8).toString('hex')}`,
},
}
);
const invoice = data?.payment?.transaction?.create_receive;
if (!invoice?.payment_request || !invoice?.payment_hash) {
throw new Error('Amboss Payments did not return a payment_request and payment_hash.');
}
return invoice;
}
function challengeHeader(invoice, token) {
return `L402 token="${token}", invoice="${invoice.payment_request}"`;
}
function paid(handler) {
return async (req, res) => {
const header = String(req.get('authorization') || '');
const match = header.match(/^(?:L402|LSAT)\s+(\S+)$/i);
const parts = match ? match[1].split(':') : [];
const token = parts.length > 1 ? parts.slice(0, -1).join(':') : '';
const preimage = parts.length > 1 ? parts[parts.length - 1] : '';
const claims = readToken(token);
const hash = preimageHash(preimage);
const resource = req.originalUrl || req.url || '/';
if (
claims
&& hash
&& hash === String(claims.payment_hash).toLowerCase()
&& String(claims.method || '').toUpperCase() === req.method.toUpperCase()
&& claims.path === resource
&& !spent.has(hash)
) {
spent.add(hash);
return handler(req, res);
}
try {
const invoice = await createInvoice(resource);
const minted = signToken({
paymentHash: invoice.payment_hash,
method: req.method,
path: resource,
});
res.set('WWW-Authenticate', challengeHeader(invoice, minted));
return res.status(402).json({
status: 'payment_required',
priceSats: PRICE_SATS,
invoice: invoice.payment_request,
});
} catch (err) {
return res.status(503).json({ error: err.message || 'Could not create an invoice.' });
}
};
}
const app = express();
app.get('/openapi.json', (_req, res) => {
res.type('json').send(fs.readFileSync(path.join(ROOT, 'openapi.json')));
});
app.get('/llms.txt', (_req, res) => {
res.type('text/plain').send(fs.readFileSync(path.join(ROOT, 'llms.txt')));
});
app.get('/v1/forecast', paid((req, res) => {
res.json({ city: req.query.q || 'Berlin', conditions: 'clear' });
}));
function readJsonBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
req.on('data', (chunk) => {
size += chunk.length;
if (size > 64 * 1024) {
reject(new Error('Request body is too large.'));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
if (!chunks.length) return resolve({});
try {
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
reject(new Error('Send a JSON object.'));
return;
}
resolve(parsed);
} catch {
reject(new Error('Send a JSON chat request.'));
}
});
req.on('error', reject);
});
}
app.post('/v1/chat/completions', paid(async (req, res) => {
let body;
try {
body = await readJsonBody(req);
} catch (err) {
return res.status(400).json({ error: err.message || 'Send a JSON chat request.' });
}
const requested = Number(body.max_tokens);
const maxTokens = Number.isFinite(requested) && requested > 0 ? Math.min(requested, MAX_TOKENS) : MAX_TOKENS;
const upstreamBody = { ...body, max_tokens: maxTokens, stream: false };
delete upstreamBody.stream_options;
try {
const upstream = await fetch(MODEL_UPSTREAM, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(upstreamBody),
signal: AbortSignal.timeout(20000),
});
const text = await upstream.text();
return res.status(upstream.status).type(upstream.headers.get('content-type') || 'application/json').send(text);
} catch (err) {
return res.status(502).json({ error: err.message || 'The model server did not answer.' });
}
}));
app.listen(PORT, () => {
console.log(`L402 starter listening on ${PORT}. GET /v1/forecast and POST /v1/chat/completions return 402 until paid.`);
});
Python
"""
Wrap one FastAPI route with L402.
Amboss Payments issues the BOLT11 invoice. This process signs the L402
token. Amboss does not document an L402 macaroon API.
Verified against the public docs (October 2026):
- GraphQL: https://app.amboss.tech/graphql
- Auth header: x-api-key
- Invoice: payment.transaction.create_receive
https://docs.amboss.tech/payments/receive-payments
- Wallet: payment.wallet.find_one { is_ready asset { symbol precision } }
https://docs.amboss.tech/payments/prompt-for-agents
TODO for Jesse: the docs show payment_hash as hex ("3b6e7d..."). This
starter assumes that value is the hex SHA256 of the 32-byte preimage.
If a live create_receive returns a different encoding, paid retries will
fail until this check is updated.
Amounts are decimal strings in the asset's minor units. For a BTC wallet
that is satoshis. Do not hard-code an asset id. Create a BTC wallet in
the dashboard (or from the live asset list, type BASE_ASSET) and set
AMBOSS_WALLET_ID.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import secrets
import time
import urllib.error
import urllib.request
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, PlainTextResponse, Response
GRAPHQL = "https://app.amboss.tech/graphql"
PRICE_SATS = max(1, int(os.environ.get("PRICE_SATS") or "21"))
ROOT = Path(__file__).resolve().parent
DOCS = ROOT.parent / "node"
if not os.environ.get("L402_TOKEN_SECRET"):
os.environ["L402_TOKEN_SECRET"] = secrets.token_hex(32)
print("L402_TOKEN_SECRET is unset. Paid retries will fail after a restart. Set a stable secret.")
spent: set[str] = set()
app = FastAPI(title="L402 seller starter")
def b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
def b64url_decode(text: str) -> bytes:
pad = "=" * ((4 - len(text) % 4) % 4)
return base64.urlsafe_b64decode(text + pad)
def sign_token(payment_hash: str, method: str, resource: str) -> str:
payload = b64url(json.dumps({
"v": 1,
"payment_hash": payment_hash.lower(),
"method": method,
"path": resource,
"exp": int(time.time()) + 3600,
}).encode())
mac = hmac.new(os.environ["L402_TOKEN_SECRET"].encode(), payload.encode(), hashlib.sha256).digest()
return f"{payload}.{b64url(mac)}"
def read_token(token: str):
payload, _, mac = str(token or "").partition(".")
if not payload or not mac:
return None
expected = hmac.new(os.environ["L402_TOKEN_SECRET"].encode(), payload.encode(), hashlib.sha256).digest()
got = b64url_decode(mac)
if len(got) != len(expected) or not hmac.compare_digest(got, expected):
return None
try:
body = json.loads(b64url_decode(payload))
except (json.JSONDecodeError, ValueError):
return None
if body.get("v") != 1 or not body.get("payment_hash"):
return None
if int(body.get("exp") or 0) < int(time.time()):
return None
return body
def preimage_hash(preimage: str):
text = str(preimage or "").strip()
try:
raw = bytes.fromhex(text) if len(text) == 64 else b64url_decode(text)
except ValueError:
return None
if len(raw) != 32:
return None
return hashlib.sha256(raw).hexdigest()
def amboss(query: str, variables: dict):
req = urllib.request.Request(
GRAPHQL,
data=json.dumps({"query": query, "variables": variables}).encode(),
headers={
"content-type": "application/json",
"x-api-key": os.environ.get("AMBOSS_API_KEY") or "",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=20) as res:
body = json.loads(res.read().decode())
if body.get("errors"):
message = "; ".join(item.get("message") or "" for item in body["errors"]).strip()
raise RuntimeError(message or "Amboss Payments returned an error.")
return body.get("data") or {}
def assert_btc_wallet(wallet_id: str):
data = amboss(
"""
query Wallet($id: String!) {
payment { wallet { find_one(id: $id) { id is_ready asset { symbol precision } } } }
}
""",
{"id": wallet_id},
)
wallet = ((data.get("payment") or {}).get("wallet") or {}).get("find_one")
if not wallet:
raise RuntimeError("Amboss Payments did not return that wallet.")
if not wallet.get("is_ready"):
raise RuntimeError("The Amboss wallet is not ready yet. A live wallet can take a while to provision liquidity.")
symbol = (wallet.get("asset") or {}).get("symbol")
if symbol and symbol != "BTC":
raise RuntimeError("This starter invoices in sats. Point AMBOSS_WALLET_ID at a BTC wallet.")
return wallet
def create_invoice(resource: str):
wallet_id = os.environ.get("AMBOSS_WALLET_ID") or ""
if not os.environ.get("AMBOSS_API_KEY") or not wallet_id:
raise RuntimeError("Set AMBOSS_API_KEY and AMBOSS_WALLET_ID.")
assert_btc_wallet(wallet_id)
data = amboss(
"""
mutation CreateReceive($input: CreateReceiveTransactionInput!) {
payment {
transaction {
create_receive(input: $input) {
id
status
payment_request
payment_hash
expires_at
}
}
}
}
""",
{
"input": {
"wallet_id": wallet_id,
"amount": str(PRICE_SATS),
"description": f"API call {resource}",
"expires_in_seconds": 3600,
"idempotency_key": f"l402-{secrets.token_hex(8)}",
}
},
)
invoice = ((data.get("payment") or {}).get("transaction") or {}).get("create_receive")
if not invoice or not invoice.get("payment_request") or not invoice.get("payment_hash"):
raise RuntimeError("Amboss Payments did not return a payment_request and payment_hash.")
return invoice
def read_doc(name: str) -> str:
for folder in (DOCS, ROOT):
file = folder / name
if file.is_file():
return file.read_text()
return ""
def challenge(request: Request):
header = request.headers.get("authorization") or ""
scheme, _, rest = header.partition(" ")
if scheme.upper() not in {"L402", "LSAT"} or not rest:
return None
token, sep, preimage = rest.rpartition(":")
if not sep:
return None
claims = read_token(token)
digest = preimage_hash(preimage)
resource = request.url.path
if request.url.query:
resource = f"{resource}?{request.url.query}"
if (
claims
and digest
and digest == str(claims.get("payment_hash") or "").lower()
and str(claims.get("method") or "").upper() == request.method.upper()
and claims.get("path") == resource
and digest not in spent
):
spent.add(digest)
return "paid"
return None
async def require_l402(request: Request):
if challenge(request) == "paid":
return None
resource = request.url.path
if request.url.query:
resource = f"{resource}?{request.url.query}"
try:
invoice = create_invoice(resource)
except Exception as err:
return JSONResponse({"error": str(err)}, status_code=503)
token = sign_token(invoice["payment_hash"], request.method, resource)
return JSONResponse(
{"status": "payment_required", "priceSats": PRICE_SATS, "invoice": invoice["payment_request"]},
status_code=402,
headers={"WWW-Authenticate": f'L402 token="{token}", invoice="{invoice["payment_request"]}"'},
)
@app.get("/openapi.json")
def openapi():
return JSONResponse(json.loads(read_doc("openapi.json") or "{}"))
@app.get("/llms.txt")
def llms():
return PlainTextResponse(read_doc("llms.txt"))
@app.get("/v1/forecast")
async def forecast(request: Request, q: str = ""):
denied = await require_l402(request)
if denied is not None:
return denied
return {"city": q or "Berlin", "conditions": "clear"}
MAX_TOKENS = max(1, int(os.environ.get("MAX_TOKENS", "256")))
MODEL_UPSTREAM = os.environ.get("MODEL_UPSTREAM", "http://127.0.0.1:11434/v1/chat/completions")
def forward_chat(payload: dict):
data = json.dumps(payload).encode()
req = urllib.request.Request(
MODEL_UPSTREAM,
data=data,
headers={"content-type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=20) as res:
return res.status, res.headers.get("content-type") or "application/json", res.read()
except urllib.error.HTTPError as err:
return err.code, "application/json", err.read()
@app.post("/v1/chat/completions")
async def chat(request: Request):
denied = await require_l402(request)
if denied is not None:
return denied
raw = await request.body()
try:
payload = json.loads(raw) if raw else {}
except json.JSONDecodeError:
return JSONResponse({"error": "Send a JSON chat request."}, status_code=400)
if not isinstance(payload, dict):
return JSONResponse({"error": "Send a JSON object."}, status_code=400)
try:
requested = int(payload.get("max_tokens"))
except (TypeError, ValueError):
requested = MAX_TOKENS
payload["max_tokens"] = max(1, min(requested, MAX_TOKENS))
payload["stream"] = False
payload.pop("stream_options", None)
try:
status, content_type, body = forward_chat(payload)
except Exception as err:
return JSONResponse({"error": str(err)}, status_code=502)
return Response(content=body, status_code=status, media_type=content_type)