Skip to main content

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 with Authorization: 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/charge to mint an invoice, pay via the wallet provider, then submit the work with the paymentId.
  • Refunds — every post-payment failure includes an LNURL-withdraw link in the refund field. 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: TIMEOUT

Async 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=abc123

Webhooks / 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-cost

Request 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");       // ← visible

Services

30+ AI services. Per-service docs live at /l402 (curl + payment examples) and the MCP tool catalog at /mcp.

Machine-readable discovery: