Developer Docs
L402 + MCP + WebLN. One scrollable page.
Quickstart — pick a flow
Three ways to use Sats4AI. Pick the one that matches your client.
L402 (HTTP + Lightning)
For agents and CLI clients. 402 challenge → pay → retry.
# 1. Hit the endpoint without auth → get a 402 + invoice
curl -i -X POST https://sats4ai.com/api/models/image \
-H "Content-Type: application/json" \
-d '{"prompt":"a cat in a tophat"}'
# Response: 402 Payment Required
# WWW-Authenticate: L402 macaroon="...", invoice="lnbc..."
# 2. Pay the invoice with any Lightning wallet, get a preimage
# 3. Re-send with Authorization header
curl -X POST https://sats4ai.com/api/models/image \
-H "Authorization: L402 <macaroon>:<preimage>" \
-H "Content-Type: application/json" \
-d '{"prompt":"a cat in a tophat"}'MCP (Claude / Cursor)
Inline payment negotiation handled by the MCP transport. Plain HTTP can't do this.
// claude_desktop_config.json (or ~/.claude.json / Cursor)
{
"mcpServers": {
"sats4ai": {
"url": "https://sats4ai.com/api/mcp"
}
}
}
// Then in your MCP client:
// 1. tools/call create_payment → returns Lightning invoice
// 2. Pay with any Lightning wallet
// 3. tools/call <tool_name> with the paymentId → results
//
// Or use lightning-wallet MCP for fully-automated payments.WebLN (Browser)
For browser apps with Alby / Bitcoin Connect.
// Browser flow with Bitcoin Connect / Alby
import { requestProvider } from "@getalby/bitcoin-connect";
const res = await fetch("/api/charge?service=image&model=...");
const { invoice, paymentId } = await res.json();
const provider = await requestProvider();
await provider.sendPayment(invoice);
// Submit the work request
const fd = new FormData();
fd.set("paymentId", paymentId);
fd.set("prompt", "a cat in a tophat");
const out = await fetch("/api/models/image", { method: "POST", body: fd });Payment Protocols
- L402 — HTTP 402 + Lightning. Macaroon issued in
WWW-Authenticate; pay invoice; resend withAuthorization: L402 macaroon:preimage. /l402. - MCP — JSON-RPC 2.0 over Streamable HTTP at
/api/mcp. Tools negotiate payment in-protocol; the agent never has to parse 402 responses. /mcp. - MPP — Machine Payment Protocol. Same surface as L402, slightly different header naming. Both are accepted on every paid endpoint.
- WebLN — for browser flows. Use
/api/chargeto mint an invoice, pay via the wallet provider, then submit the work with thepaymentId. - Refunds — every post-payment failure includes an LNURL-withdraw link in the
refundfield. No support tickets needed.
Macaroon Caveats
Macaroons we issue are fail-closed: any unrecognised caveat rejects the request. Currently enforced:
RequestPath— macaroon binds to the exact endpoint path used for the 402 challenge.ExpiresAt— typically 10 minutes from issuance.PaymentHash— bound to the Lightning invoice; preimage required.Service+Model— bound to the model that priced the call. Auto-routed calls must be retried with the routed model id (see Auto-Routing below).
Error Codes
Every error response includes a machine-readable error_code in both the JSON body and the X-Error-Code header. Full catalog at /api/error-codes.
curl https://sats4ai.com/api/error-codes
# {
# "version": 1,
# "codes": {
# "TIMEOUT": "Request or upstream provider timed out. Retry later.",
# "CONTENT_FILTERED": "Output blocked by safety/content moderation. Rephrase the prompt.",
# "L402_INVALID_PARAMS": "Pre-payment validation failed. No invoice was created; no sats charged.",
# "L402_REFUND_ISSUED": "Response payload includes a refund object with an LNURL-withdraw link.",
# ...
# }
# }
# Every error response includes:
# - JSON: { "error": "...", "error_code": "TIMEOUT" }
# - Header: X-Error-Code: TIMEOUTAsync Jobs
Long-running services (audiobook, video, transcription, AI calls, 3D models) return HTTP 202 with a standard shape. Poll the poll_url at poll_interval_ms.
# 1. Pay + submit a long-running job
curl -X POST https://sats4ai.com/api/models/epub-audiobook \
-H "Authorization: L402 ..." \
-F "file=@book.epub" -F "voice=Ashley"
# Response: 202 Accepted
# {
# "status": "queued",
# "job_id": "abc123",
# "poll_url": "https://sats4ai.com/api/models/epub-audiobook/status?id=abc123",
# "poll_interval_ms": 3000,
# "estimated_completion_ms": 600000
# }
# Headers: X-Job-Id, X-Poll-Url, X-Poll-Interval-Ms
# 2. Poll the status URL until status === "completed"
curl https://sats4ai.com/api/models/epub-audiobook/status?id=abc123Webhooks / Callbacks
Skip the polling loop. Pass callback_url + callback_id on any async job and we'll POST you when it finishes. HMAC-signed, SSRF-validated, opt-in.
# OPT-IN — include callback_url + callback_id in your async request.
# Polling keeps working; the webhook is a supplement, not a replacement.
curl -X POST https://sats4ai.com/api/models/epub-audiobook \
-H "Authorization: L402 ..." \
-F "file=@book.epub" -F "voice=Ashley" \
-F "callback_url=https://your-app.example.com/hooks/sats4ai" \
-F "callback_id=user-42-job-7"
# Response: 202 Accepted
# {
# "status": "queued",
# "job_id": "abc123",
# "poll_url": "...",
# "callback_id": "user-42-job-7",
# "callback_registered": true,
# "callback_secret": "<per-job 64-hex HMAC secret>"
# }
# When the job finishes we POST your callback_url with:
# Headers: X-Sats4AI-Signature: sha256=<hex>
# Body: {
# "job_id": "abc123",
# "callback_id": "user-42-job-7",
# "status": "completed" | "failed",
# "result_url" | "error_code" + "error",
# "timestamp": "2026-04-13T..."
# }
# Verify the signature (Node):
# const mac = crypto.createHmac("sha256", callback_secret)
# .update(rawBody).digest("hex");
# const ok = req.headers["x-sats4ai-signature"] === "sha256=" + mac;
# Validation:
# - callback_url MUST be public HTTPS (no localhost / private ranges / IP literals in RFC1918)
# - callback_id is OPAQUE, max 128 chars, no control chars
# → do NOT embed PII or secrets; it is logged server-side
# - rejection → response includes "callback_registered": false + a reason.
# Your job still runs; poll poll_url instead.
# - retries: at 0s / 5s / 30s. 4xx aborts. Best-effort (single instance).Privacy note: callback_id is echoed back in our logs and the webhook body — treat it as an opaque correlation string, not a place to stash user data. Validation rejects http://, loopback, link-local, and RFC1918 hosts so an attacker can't point us at your metadata service.
Auto-Routing
For categories with multiple models (text, image, audio), pass "model": "auto" to let the server pick the best default. The chosen model id is returned in the X-Route-Model response header.
# Send model: "auto" — server picks the best for the category
curl -i -X POST https://sats4ai.com/api/models/image \
-H "Content-Type: application/json" \
-d '{"prompt":"a cat","model":"auto"}'
# Response: 402 Payment Required
# X-Route-Model: nano-banana-2 ← chosen model id
# X-Route-Category: Image
# X-Error-Code: L402_AUTO_ROUTED
# IMPORTANT: on the paid retry, send the *routed* model id, NOT "auto":
curl -X POST https://sats4ai.com/api/models/image \
-H "Authorization: L402 ..." \
-d '{"prompt":"a cat","model":"nano-banana-2"}'
# Otherwise the macaroon's price-per-model caveat may reject the request.URL-path model selection
Convenience for clients that prefer paths over body fields:
# Convenience: model in URL path
curl -X POST https://sats4ai.com/api/m/text-generation/gpt-oss-120b \
-H "Content-Type: application/json" \
-d '{"prompt":"hello"}'
# Forwards to /api/models/text-generation with model injected.
# Body field still wins if both are set.Estimate Cost
Pre-payment quotes for budget-aware agents. No auth, no side effects. /api/estimate-cost with no params returns the catalog.
# Get a quote before paying
curl 'https://sats4ai.com/api/estimate-cost?service=text-to-speech&model=omnivoice&chars=1500'
# {
# "service": "text-to-speech",
# "amount_sats": 15,
# "currency": "BTC",
# "breakdown": { "type": "per-character", "chars": 1500, "chars_per_sat": 100, "model": "omnivoice" },
# "error_code": "L402_ESTIMATE_ONLY"
# }
# List the catalog
curl https://sats4ai.com/api/estimate-costRequest Deduplication
Identical POST bodies from the same IP within 30 seconds are returned from cache. Useful when an agent retries due to a network blip and you don't want to be charged twice.
# Identical POST within 30s returns the cached response (no new payment)
# Response headers:
# X-Dedup: hit ← cached
# X-Dedup: miss ← fresh
# Bypass the cache with X-No-Cache: true
curl -X POST https://sats4ai.com/api/models/image \
-H "X-No-Cache: true" -d '{...}'
# Note: dedup is best-effort, single-instance, 30s TTL.
# For strong idempotency use a unique paymentId per job.CORS / Browser Use
All payment + routing headers are explicitly listed in Access-Control-Expose-Headers so browser fetch() can read them.
# Headers exposed to browser fetch():
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-No-Cache,
Payment-Signature, X-Cashu
Access-Control-Expose-Headers: WWW-Authenticate, Payment-Receipt,
Payment-Required, X-Route-Model, X-Route-Category, X-Dedup,
X-Job-Id, X-Poll-Url, X-Poll-Interval-Ms, X-Error-Code
# So in the browser:
const res = await fetch("/api/models/image", { method: "POST", body });
res.headers.get("WWW-Authenticate"); // ← visible
res.headers.get("X-Error-Code"); // ← visibleServices
30+ AI services. Per-service docs live at /l402 (curl + payment examples) and the MCP tool catalog at /mcp.
Machine-readable discovery:
- /.well-known/l402-services — L402 manifest
- /.well-known/mcp — MCP server manifest
- /api/mcp/discovery — MCP tool catalog with pricing + performance
- /api/discover?q=... — semantic search
- /api/l402/models — model + tier listing