← All Posts

Build an L402 API Paywall with Cloudflare Workers

2026-02-09 — lightning, tutorial, L402, cloudflare

What Is L402?

HTTP has a status code for payments: 402 Payment Required. It sat unused for decades. L402 gives it a purpose. When a client hits your API without paying, you return a 402 with a Lightning invoice. The client pays, retries with proof, and gets the response.

No API keys to manage. No subscriptions. No billing dashboards. Every request is self-contained: pay and get data.

What We're Building

A Cloudflare Worker that serves an API endpoint. First request returns a 402 with a Lightning invoice. After payment, the same endpoint returns data. The whole thing runs on the free tier.

Stack:

Step 1: Set Up Your Lightning Wallet

You need a wallet that can create and check invoices via API. Two options:

Option A: Alby Hub (recommended)

Alby Hub gives you a self-hosted Lightning node with NWC (Nostr Wallet Connect) and LNbits built in. Install it, fund your node, and enable the LNbits extension. Your API key is on the LNbits dashboard.

Option B: Standalone LNbits

If you already run a Lightning node (LND, CLN), install LNbits and connect it. The API key is on the wallet page under "API info."

Either way, you need two things:

Step 2: Create the Worker

Initialize a new Cloudflare Worker project:

npm create cloudflare@latest l402-api -- --type=hello-world
cd l402-api

Replace src/index.js with this:

const PRICE_SATS = 21;

export default {
  async fetch(request, env) {
    if (request.method !== "POST") {
      return new Response(JSON.stringify({
        service: "My L402 API",
        usage: 'POST with {"prompt": "your question"}',
        price: PRICE_SATS + " sats per request"
      }), { headers: { "Content-Type": "application/json" } });
    }

    const body = await request.json().catch(() => null);
    if (!body?.prompt) {
      return new Response(
        JSON.stringify({ error: "prompt is required" }),
        { status: 400, headers: { "Content-Type": "application/json" } }
      );
    }

    // Check for payment proof
    const paymentHash = body.payment_hash || "";
    if (paymentHash) {
      const paid = await checkPayment(env, paymentHash);
      if (paid) {
        // Payment verified — return the goods
        return new Response(JSON.stringify({
          result: "This is your paid API response for: " + body.prompt
        }), { headers: { "Content-Type": "application/json" } });
      }
      return new Response(
        JSON.stringify({ error: "payment not confirmed yet" }),
        { status: 402, headers: { "Content-Type": "application/json" } }
      );
    }

    // No payment — create invoice and return 402
    const invoice = await createInvoice(env, PRICE_SATS, "API query");
    if (!invoice) {
      return new Response(
        JSON.stringify({ error: "failed to create invoice" }),
        { status: 500, headers: { "Content-Type": "application/json" } }
      );
    }

    return new Response(JSON.stringify({
      status: "payment_required",
      price_sats: PRICE_SATS,
      payment_request: invoice.payment_request,
      payment_hash: invoice.payment_hash,
      instructions: "Pay the invoice, then POST again with payment_hash in the body."
    }), {
      status: 402,
      headers: {
        "Content-Type": "application/json",
        "WWW-Authenticate": `L402 invoice="${invoice.payment_request}", payment_hash="${invoice.payment_hash}"`
      }
    });
  }
};

async function createInvoice(env, amount, memo) {
  const resp = await fetch(`${env.LNBITS_URL}/api/v1/payments`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Api-Key": env.LNBITS_KEY
    },
    body: JSON.stringify({ out: false, amount, memo })
  });
  if (resp.status !== 201) return null;
  return resp.json();
}

async function checkPayment(env, hash) {
  const resp = await fetch(`${env.LNBITS_URL}/api/v1/payments/${hash}`, {
    headers: { "X-Api-Key": env.LNBITS_KEY }
  });
  const data = await resp.json();
  return data.paid === true;
}

Step 3: Configure Secrets

Add your LNbits credentials as Worker secrets:

npx wrangler secret put LNBITS_URL
# Enter: https://your-lnbits.example.com

npx wrangler secret put LNBITS_KEY
# Enter: your-invoice-read-key

Step 4: Deploy

npx wrangler deploy

Your L402 API is live. Test it:

# Step 1: Get the invoice
curl -X POST https://l402-api.your-account.workers.dev \
  -H "Content-Type: application/json" \
  -d '{"prompt": "Hello"}'

# Response: 402 with payment_request and payment_hash

# Step 2: Pay the invoice with any Lightning wallet

# Step 3: Retry with proof
curl -X POST https://l402-api.your-account.workers.dev \
  -H "Content-Type: application/json" \
  -d '{"prompt": "Hello", "payment_hash": "abc123..."}'

# Response: 200 with your data

How the Flow Works

Client                         Worker                    LNbits
  |                              |                         |
  |-- POST {prompt} ----------->|                         |
  |                              |-- Create invoice ------>|
  |                              |<-- payment_request -----|
  |<-- 402 + invoice ---------- |                         |
  |                              |                         |
  | (user pays invoice)          |                         |
  |                              |                         |
  |-- POST {prompt, hash} ----->|                         |
  |                              |-- Check payment ------->|
  |                              |<-- paid: true ----------|
  |<-- 200 + data --------------|                         |

Adding the WWW-Authenticate Header

The L402 spec puts the invoice in the WWW-Authenticate header so clients can auto-detect paywalled endpoints:

WWW-Authenticate: L402 invoice="lnbc210n1...", payment_hash="abc123..."

Smart clients (like Fewsats or MCP tools) can parse this header and pay automatically. The JSON body is for humans and simpler clients.

Production Tips

Add a free tier

Give each IP one free request per day. Store the IP hash in KV:

// In wrangler.toml:
// [[kv_namespaces]]
// binding = "RATE_KV"
// id = "your-kv-namespace-id"

const ipHash = await crypto.subtle.digest(
  "SHA-256",
  new TextEncoder().encode(clientIp + new Date().toDateString())
);
const key = "free:" + btoa(String.fromCharCode(...new Uint8Array(ipHash)));
const used = await env.RATE_KV.get(key);
if (!used) {
  await env.RATE_KV.put(key, "1", { expirationTtl: 86400 });
  // Serve free response
}

Support the Authorization header

Some L402 clients send proof via header instead of body:

const authHeader = request.headers.get("Authorization") || "";
const match = authHeader.match(/^L402\s+(\S+)/i);
if (match) {
  const paymentHash = match[1].split(":")[0];
  // Verify payment...
}

Add CORS for browser clients

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "POST, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type, Authorization"
};

Real Example

This tutorial is based on a live API. Try it:

curl -X POST https://maximumsats.com/api/dvm \
  -H "Content-Type: application/json" \
  -d '{"prompt": "What is the Lightning Network?"}'

First query is free. After that, you'll get a 402 with a 21-sat invoice.

Consuming an L402 API (Client Side)

Building the server is half the story. Here's how to consume an L402 API using the Alby SDK to pay invoices programmatically via NWC:

import { LN } from "@getalby/sdk/lnclient";

async function fetchL402(url, body, nwcCredentials) {
  // Step 1: Make the initial request
  const res = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body)
  });

  // Step 2: If not 402, return as-is
  if (res.status !== 402) return res.json();

  // Step 3: Parse the invoice from the 402 response
  const data = await res.json();
  const { payment_request, payment_hash } = data;

  // Step 4: Pay the invoice via NWC
  const ln = new LN(nwcCredentials);
  await ln.pay(payment_request);
  ln.close();

  // Step 5: Retry with proof of payment
  const paid = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ ...body, payment_hash })
  });
  return paid.json();
}

// Usage:
const result = await fetchL402(
  "https://maximumsats.com/api/dvm",
  { prompt: "What is Lightning?" },
  "nostr+walletconnect://..."
);
console.log(result);

This pattern works with any L402 API. The NWC connection handles the payment automatically — no manual wallet interaction needed. Use a dedicated NWC connection with limited permissions (pay_invoice only) and a spending cap.

Why This Matters

L402 turns every API into a vending machine. No signup, no billing, no chargebacks. The payment IS the authentication. This is how APIs should work in a world with programmable money.

What to build with this:

The entire deployment costs $0. Revenue is 100% margin.

Found this useful?

Send a tip via Lightning. One click, no account needed.

Tip 100 sats ⚡