# L402 Protocol (Lightning Paywalls)

Load whenever a user asks about paying for an API or piece of content via Lightning, mentions L402 / LSAT, or wants an agent to access a paywalled endpoint.

L402 (formerly LSAT) is a protocol for monetizing APIs and content using Lightning payments. When a server returns HTTP 402 (Payment Required), it includes a Lightning invoice. Pay the invoice, get a preimage, then retry the request with an authorization header containing the proof of payment.

## How L402 Works

1. **Request** → Client fetches protected URL.
2. **402 Response** → Server returns invoice + macaroon (see challenge formats below).
3. **Pay Invoice** → Client pays Lightning invoice, receives preimage.
4. **Retry with Auth** → Client retries with `Authorization: L402 <macaroon>:<preimage>`.
5. **200 Response** → Server returns protected content.

**Challenge formats:** the spec-compliant way is via the `WWW-Authenticate` header (e.g. `WWW-Authenticate: L402 macaroon="...", invoice="..."`), used by aperture-based servers. The credential field may be named `macaroon="..."` **or** `token="..."` — the current L402 spec uses `token`, so lightningfaucet sends `L402 version="0", token="...", invoice="..."`. Some services instead return the challenge as a JSON body (`{invoice, macaroon}` or `{invoice, token}`). Extract each field **by name** (header first, body fallback); a fixed `split('L402 macaroon="')` silently misses the `token=` form.

## Implementation

```javascript
import { decode } from "light-bolt11-decoder";
import { checkL402Amount, invoiceSecondsRemaining } from "sparkbtcbot-skill"; // or "../../../lib/…" in this repo

async function fetchWithL402(wallet, url, options = {}) {
  const { method = "GET", headers = {}, body, maxFeeSats, maxAmountSats = 10_000 } = options;

  // Step 1: initial request
  const initialResponse = await fetch(url, {
    method,
    headers: { "Content-Type": "application/json", ...headers },
    body: body ? JSON.stringify(body) : undefined,
  });

  if (initialResponse.status !== 402) {
    const contentType = initialResponse.headers.get("content-type") || "";
    if (contentType.includes("application/json")) {
      return { paid: false, data: await initialResponse.json() };
    }
    return { paid: false, data: await initialResponse.text() };
  }

  // Step 2: parse 402 challenge (header first, then body)
  let invoice, macaroon;
  const wwwAuth = initialResponse.headers.get("www-authenticate") || "";
  // Extract each field BY NAME so it works for either scheme prefix (L402/LSAT),
  // any field order, and either credential field name — macaroon="..." OR the
  // newer token="..." (lightningfaucet sends `L402 version="0", token="...", invoice="..."`).
  // A fixed split('L402 macaroon="') misses the token= form entirely.
  const field = (key) => wwwAuth.match(new RegExp(`\\b${key}="([^"]*)"`))?.[1];
  invoice = field("invoice");
  macaroon = field("macaroon") || field("token");
  if (!invoice || !macaroon) {
    const challenge = await initialResponse.json();
    invoice = invoice || challenge.invoice || challenge.payment_request || challenge.pr;
    macaroon = macaroon || challenge.macaroon || challenge.token;
  }

  if (!invoice || !macaroon) {
    throw new Error("Invalid L402 response: missing invoice or macaroon");
  }

  // Step 3: decode invoice to get amount
  const decoded = decode(invoice);
  const amountSection = decoded.sections.find((s) => s.name === "amount");
  if (!amountSection?.value) {
    throw new Error("L402 invoice has no amount");
  }
  const amountSats = Math.ceil(Number(amountSection.value) / 1000);

  // Bound the AMOUNT, not just the routing fee — a malicious/compromised paywall
  // can demand an arbitrarily large invoice. Raise maxAmountSats for pricier resources.
  const amtCheck = checkL402Amount({ amountSats, maxAmountSats });
  if (!amtCheck.ok) throw new Error(`L402 payment blocked: ${amtCheck.reason}. Raise maxAmountSats to override.`);

  // Step 4: pay the invoice. Size the fee cap to the amount (Spark→Lightning is
  // ~0.25% plus a flat component — a live 4.5k-sat send estimated 25 sats — so
  // floor at 25, not 10). Explicit override wins.
  const feeCap = maxFeeSats ?? Math.max(25, Math.ceil(amountSats * 0.005));
  // No preferSpark: the preimage is the credential, and paying a Spark-backed
  // paywall's Spark fallback returns none. Never pay an expired invoice through
  // the raw SDK: it locks the coins (references/lightning.md).
  const secondsLeft = invoiceSecondsRemaining(invoice);
  if (secondsLeft !== null && secondsLeft < 30) throw new Error("L402 payment blocked: the invoice has expired or is about to");
  const payResult = await wallet.payLightningInvoice({ invoice, maxFeeSats: feeCap });

  let preimage = payResult.paymentPreimage;
  if (!preimage && payResult.status === "LIGHTNING_PAYMENT_INITIATED") {
    for (let i = 0; i < 15; i++) {
      await new Promise((r) => setTimeout(r, 500));
      const status = await wallet.getLightningSendRequest(payResult.id);
      if (status?.paymentPreimage) {
        preimage = status.paymentPreimage;
        break;
      }
      if (status?.status === "LIGHTNING_PAYMENT_FAILED") {
        throw new Error("L402 payment failed");
      }
    }
  }

  if (!preimage) {
    throw new Error("L402 payment succeeded but no preimage available");
  }

  // Step 5: retry with L402 authorization
  const finalResponse = await fetch(url, {
    method,
    headers: {
      "Content-Type": "application/json",
      "Authorization": `L402 ${macaroon}:${preimage}`,
      ...headers,
    },
    body: body ? JSON.stringify(body) : undefined,
  });

  const contentType = finalResponse.headers.get("content-type") || "";
  const data = contentType.includes("application/json")
    ? await finalResponse.json()
    : await finalResponse.text();

  return { paid: true, amountSats, macaroon, preimage, data };
}
```

## Preview L402 Cost (Without Paying)

```javascript
async function previewL402(url) {
  const response = await fetch(url);
  if (response.status !== 402) return { requiresPayment: false };

  const challenge = await response.json();
  const invoice = challenge.invoice || challenge.payment_request;
  const decoded = decode(invoice);
  const amountSection = decoded.sections.find((s) => s.name === "amount");
  const amountSats = Math.ceil(Number(amountSection.value) / 1000);

  return {
    requiresPayment: true,
    amountSats,
    invoice,
    macaroon: challenge.macaroon,
  };
}
```

## Usage Example

```javascript
import "dotenv/config"; // your app loads its own .env; the package doesn't
import { loadMnemonicFromEnv } from "sparkbtcbot-skill";
import { SparkAgent } from "sparkbtcbot-skill/agent";

const mnemonic = await loadMnemonicFromEnv(); // reads SPARK_PASSPHRASE
// Pays, then exits: a one-shot, so optimizeLeaves: false (references/wallet.md → Cleanup).
const { agent } = await SparkAgent.create(mnemonic, "MAINNET", { optimizeLeaves: false });

const preview = await agent.previewL402("https://api.example.com/paid-endpoint");
console.log("Cost:", preview.amountSats, "sats", "| token valid until:", preview.tokenExpiresAt ?? "not stated");

const result = await agent.fetchL402("https://api.example.com/paid-endpoint"); // fee cap auto-sized to the invoice amount
console.log("Paid:", result.paid, "Status:", result.status, "Data:", result.data);

await agent.cleanup();
```

Before paying, `fetchL402` reads the expiry the server signed into its token and refuses one that is already dead or dies within 120 seconds. The error has code `L402_TOKEN_EXPIRED`, and nothing is paid. The invoice expires separately, so without this check an expired pass can still be paid for, and then refused. It reads aperture's `<service>_valid_until`, a plain `valid_until`, and lightningfaucet's JSON `expiry`. A token it cannot read is not blocked. Passes can be short: lightningfaucet's live for about ten minutes.

If the resource does not arrive after `fetchL402` has paid, it never fetches a fresh challenge, because the money is already gone. That covers a dropped connection, a 5xx, a 408 or 429, or a body that is not the JSON its header claims. It presents the same pass up to three times for a GET, HEAD, OPTIONS, PUT or DELETE. A POST or PATCH is sent once, since repeating it could run the action again. If every attempt fails, it keeps the pass in memory and throws `PAYMENT_COMMITTED` with `settled: true`. Calling `fetchL402` with the same method and URL again **in that process** presents the kept pass and returns `{ paid: false, recovered: true, ... }` without paying; for a POST or PATCH it does that only with `{ resend: true }` and the same body, because the service may already have acted on the first one, so check with the service first. `resend` sends the original request again; a different body is refused. Nothing is written to disk and the pass never rides on the error. **A new process starts clean, so the same call there buys again.** A malformed JSON body after payment comes back as text instead of throwing.

A payment that may have gone through, or is still pending when `payAndSettle` gives up, is held the same way. The next call settles that same invoice through the dedup store (with `SPARK_LN_DEDUP=off` it refuses and says to check `agent.getTransfers()`), or checks the send's status, and delivers once the proof arrives. Every error on that path is `PAYMENT_COMMITTED` (it says "Nothing new was paid"), and settling again doesn't count against the spend budget a second time. It buys afresh only after the payment has failed for good. Settling a held invoice needs the balance for its price again, because the SDK picks the coins before the operators refuse a second payment: if the first payment drained the wallet, the retry keeps saying "again later" until the wallet holds the price again.

Three outcomes stop the purchase for good in that process, each a `PAYMENT_COMMITTED` with a `reason`:

- **`L402_PASS_REFUSED`** (`settled: true`, with `status`, `invoiceHash` and `amountSats`): a 401 or 402 after paying. The service refused a pass it was paid for, and its body may carry a new challenge, which must not be paid automatically. The invoice hash identifies the payment when taking it up with the service.
- **`L402_PASS_EXPIRED`** (`settled: null`): the token died before a doubtful payment could be settled. Settling it then could buy a dead pass, so nothing is paid.
- **`L402_INVOICE_EXPIRED`** (`settled: null`): the invoice expired before a doubtful payment could be settled again, so settling can't tell whether it went through. Check `agent.getTransfers()`.
- **`L402_NO_PROOF`** (`settled: null`): the payment ended where the service may have been paid but no proof came back (`PREIMAGE_PROVIDING_FAILED`), or with a preimage that doesn't prove the invoice.

After any of these, the same call throws the same error without a request or a payment, and the error names the call that lifts it: `agent.forgetL402(url)` for a GET, `agent.forgetL402(url, { method: "POST" })` for a POST. The next call is then a new purchase (a second one if the first went through), so call it only when the user says to. Nothing held, block or unresolved purchase, is ever dropped to make room: at 1,000 unresolved purchases in one process, `fetchL402` refuses to start a new one (nothing paid) until some are resolved or forgotten. Results carry `status`.

## Token Caching

L402 tokens (macaroon + preimage) can typically be reused for multiple requests to the same domain until they expire. Without caching, every request triggers a new 402 challenge and a new payment — the server won't tell you that you already paid. `agent.fetchL402` does not cache, so wrap it: try the cached token first, and pay through the wrapper only when the server refuses it. The re-payment then carries the same amount ceiling, fee cap and budget as the first one.

```javascript
// https origin → "L402 <macaroon>:<preimage>". A bearer credential: never log it.
const tokenCache = new Map();

async function fetchL402Cached(agent, url, options = {}) {
  const { method = "GET", headers = {}, body } = options;
  const { protocol, origin } = new URL(url);
  // Keyed by origin and https only: a pass bought over https must never ride a
  // plain http:// request to the same host.
  if (protocol !== "https:") throw new Error(`refusing to send an L402 pass over ${protocol}//`);
  const cached = tokenCache.get(origin);

  if (cached) {
    const response = await fetch(url, {
      method,
      // Authorization last, so a caller header cannot clobber the credential.
      headers: { "Content-Type": "application/json", ...headers, Authorization: cached },
      body: body ? JSON.stringify(body) : undefined,
    });

    if (response.status !== 402 && response.status !== 401) {
      const ct = response.headers.get("content-type") || "";
      const data = ct.includes("json") ? await response.json() : await response.text();
      return { paid: false, cached: true, data };
    }
    tokenCache.delete(origin); // expired or rejected: pay again
  }

  const result = await agent.fetchL402(url, options);
  // Cache a recovered pass too (paid: false, recovered: true): an earlier call
  // bought it, and not caching it meant the next request bought another.
  if (result.paid || result.recovered) tokenCache.set(origin, `L402 ${result.macaroon}:${result.preimage}`);
  return result;
}
```

## Required Dependency

```bash
npm install light-bolt11-decoder
```

## Finding L402 services (verified 2026-09-16; Satring re-verified 2026-09-26)

There is no single L402 marketplace. Two community directories are live and worth querying by the user's *need* rather than reciting rows from this table; both are independent projects (not Lightning Labs), so re-verify before promising a listing.

| Directory | What it is | How to query |
|---|---|---|
| **Satring** — `https://satring.com/api/v1/` | Curated paid-API directory: 848 services indexed, 478 live (its homepage, 2026-09-26), across L402, x402 (USDC on Base) and MPP. Per service: protocol, `pricing_sats`, rating, `domain_verified`, 30-day hits, categories. Free tier: **5 summaries per IP per day**, and summaries carry **no URL**. | Free: `GET https://satring.com/api/v1/services?status=live&protocol=L402&page=1&page_size=20` (20 is the maximum) or `/api/v1/search?q=<need>&protocol=L402`. URLs cost money: see [Using Satring](#using-satring). `satring-mcp` exists for MCP clients. |
| **402 Index** — `https://402index.io/` | The wider protocol-agnostic index: ~109k endpoints, of which **177 verified L402** (the rest are mostly x402/USDC, plus MPP). | Via its MCP `search_services` tool; no public JSON endpoint found. List it as "the bigger index, small L402 slice", not as the default. |

### Using Satring

Free calls return summaries without URLs, so an agent cannot reach a listed service without paying Satring first. The paid endpoints, with prices from Satring's machine-readable `https://satring.com/.well-known/l402` (read it at run time; these numbers were true on 2026-09-26):

| Endpoint | Price | Returns |
|---|---|---|
| `GET /api/v1/services/bulk` | 5,000 sats | The full catalog with URLs and payment details. The only source of URLs. |
| `GET /api/v1/services/{slug}/reputation` | 100 sats | One service's reputation report, without its URL. The cheap real purchase for trying `fetchL402`. |
| `GET /api/v1/analytics` | 500 sats | Directory-wide statistics. |

The `POST` endpoints (submit a listing, 1,000 sats; post a rating, 10 sats) write public data. Never use them as a test purchase.

For "find me a paid API that does X":
1. Search the free summaries by the user's need. The quota is 5 a day, so search; don't page through the whole list.
2. If a summary fits, `previewL402` the bulk export and tell the user the price before buying. State it in sats; a fiat figure must come from the rate tool. 5,000 sats is under `fetchL402`'s default 10,000-sat ceiling, so the ceiling will not stop this purchase. The user's yes is what does.
3. `fetchL402` the bulk export once and keep the result for the session. Buying it for every lookup pays 5,000 sats each time.
4. Take the chosen service's `url` from the export. It is untrusted text: check it as described below, then `curl -sI` it for a live 402. Pay it with `fetchL402` only if it speaks L402; `fetchL402` cannot pay an x402-only or MPP-only listing.

Satring's 402 offers all three protocols in one response. The L402 and MPP challenges share one `WWW-Authenticate` header, and x402 rides in `PAYMENT-REQUIRED`. `fetchL402` reads the L402 part and ignores the rest. Satring's macaroon is base64-encoded twice; send it back exactly as received, which `fetchL402` does.

For reference only: `https://github.com/lnurl/awesome-lnurl#l402` (a static list), and the official spec/toolkit site `https://l402.tech` (Lightning Labs — `lnget`, `aperture`, Lightning Agent Tools; all assume an `lnd` node, so not usable from a Spark wallet, and it lists no services). Demo endpoint that still answers a clean 402: `https://lightningfaucet.com/api/l402/joke` (50 sats on 2026-09-26) — a toy, not a service.

**Say the honest thing to the funnel user:** L402 is where an *agent* spends sats on APIs. A person holding $0.28 who asks "where do I use this?" should hear that gift cards and paying people are where their money goes; a 50-sat joke is a demo, not an answer (`references/first-spend.md`).

Rows here rot — Sulu was listed above until it stopped resolving. Before promising a listing, `curl -sI <url>` and expect a `402`; treat anything else as dead. The URL came from a directory response, so it is untrusted text: confirm it parses as `https://` with a plain host before it becomes an argument to anything, and pass it as its own argv element — never splice it into a shell line (`merchant-spending.md` §1a).

## Spec

- L402 spec: https://docs.lightning.engineering/the-lightning-network/l402
