Back to Guides

Pay-per-API-call with Bitcoin micropayments

Charge for each request to an Express or ASP.NET Core API with L402: one Lightning payment per call, fixed or computed prices, tested with curl.

Updated October 23, 2026View as Markdown
  • L402
  • API monetization
  • Micropayments
  • Lightning
  • TypeScript
  • C#

This guide puts a price on an API's routes with L402. A request without a valid token gets 402 Payment Required and a Lightning invoice; the client pays it, sends the request again with proof of payment, and your handler runs. The money goes to an ln.bot wallet, and there are no accounts for callers to create. How L402 works covers the protocol itself, and x402 vs L402 compares it with the stablecoin version of the same idea.

You need an ln.bot wallet and its wallet key (wk_...); give your AI agent a Bitcoin wallet shows how to get both. The server needs no other credential, and the wallet key reaches only that wallet. You also need Node.js 18 or later, or .NET 8.

Paywall an Express route

npm install express @lnbot/l402
bash
import express from "express";
import { l402, LnBot } from "@lnbot/l402";

const app = express();
const ln = new LnBot({ apiKey: process.env.LNBOT_API_KEY }); // wk_... for the wallet that gets paid
const walletId = process.env.LNBOT_WALLET_ID!;              // wal_...

// Every request under /api/premium costs ₿10
app.use("/api/premium", l402.paywall(ln, { walletId, price: 10, description: "Weather API" }));

app.get("/api/premium/weather", (req, res) => {
  res.json({ forecast: "sunny", paymentHash: req.l402?.paymentHash });
});

// Routes outside /api/premium stay free
app.get("/health", (req, res) => {
  res.json({ status: "ok" });
});

app.listen(3000);
typescript

The paywall is ordinary Express middleware, so it can also sit in a single route's chain: app.post("/api/report", l402.paywall(ln, { walletId, price: 50 }), handler). After a request is paid, req.l402 holds its paymentHash, the token's caveats and the price it paid.

Or an ASP.NET Core route

dotnet add package LnBot.L402.AspNetCore
bash
using LnBot;
using LnBot.L402;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(new LnBotClient(builder.Configuration["LNBOT_API_KEY"]));

var app = builder.Build();
var walletId = app.Configuration["LNBOT_WALLET_ID"]!;

// Every request under /api/premium costs ₿10
app.UseL402Paywall("/api/premium", new L402Options { WalletId = walletId, Price = 10, Description = "Weather API" });

app.MapGet("/api/premium/weather", (HttpContext context) =>
    Results.Ok(new { forecast = "sunny", paymentHash = context.GetL402()?.PaymentHash }));
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));

app.Run();
csharp

The paywall needs LnBotClient registered in DI, as above. For one minimal-API endpoint, use a filter instead of a path prefix:

app.MapGet("/api/hourly", () => Results.Ok(new { hours = 24 }))
   .AddEndpointFilter(new L402EndpointFilter(walletId, price: 5, description: "Hourly forecast"));
csharp

On a controller action, use the attribute (with builder.Services.AddControllers() and app.MapControllers() in Program.cs). Attribute arguments must be constants, so the wallet ID goes in as a literal or a const:

using LnBot.L402;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/[controller]")]
public class ForecastController : ControllerBase
{
    [L402(WalletId = "wal_...", Price = 50, Description = "Ten-day forecast")]
    [HttpGet("ten-day")]
    public IActionResult TenDay() => Ok(new { days = 10 });
}
csharp

Set the price

A fixed price per route is one paywall per route:

app.use("/api/search", l402.paywall(ln, { walletId, price: 1 }));
app.use("/api/generate", l402.paywall(ln, { walletId, price: 100 }));
typescript

To price each request, pass a function of the request (it may be async, to look a price up somewhere):

// ₿1 per row asked for, from ₿1 to ₿500
app.use("/api/rows", l402.paywall(ln, {
  walletId,
  price: (req) => Math.min(500, Math.max(1, Number(req.query.limit) || 10)),
}));
typescript

A token is bound to the price it was paid at and works for that price or less, so a token bought for ?limit=10 won't open ?limit=400. In C#, PriceFactory = context => Task.FromResult(...) on L402Options does the same.

To give away the first requests, decide before the paywall runs. Create the paywall once, outside the handler: each paywall keeps its own record of spent tokens, and one created per request would accept a spent token again.

const paid = l402.paywall(ln, { walletId, price: 5 });
const freeCalls = new Map<string, number>();

app.use("/api/lookup", (req, res, next) => {
  const used = freeCalls.get(req.ip ?? "") ?? 0;
  if (used < 100) {
    freeCalls.set(req.ip ?? "", used + 1); // the first 100 calls per IP are free
    return next();
  }
  return paid(req, res, next);
});
typescript

What a payment buys

In version 1.1 of both libraries, one payment buys one request:

  • Each challenge carries two caveats, lnbot_path and lnbot_price, so its token opens only the path it was issued for (without the query string) at that price or less. A holder can add caveats to a token but can't remove them.
  • Tokens expire after an hour. expirySeconds changes that.
  • A token is spent when the request reaches your handler, even if the handler then fails. A client that paid for a request your handler couldn't serve has paid for nothing, so catch errors you can recover from.
  • Spent tokens are remembered in the process's memory. That memory is empty after a restart and isn't shared: if you run several instances or serverless functions, a token spent on one can be spent again on another. Give them one shared spentStore.

A spentStore needs one method, claim, which must succeed once per payment hash. With Redis (npm install redis):

import { createClient } from "redis";
import type { SpentStore } from "@lnbot/l402";

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

const spentStore: SpentStore = {
  // SET ... NX EX: only the first claim of a payment hash succeeds, and the key outlives the token
  async claim(paymentHash, ttlSeconds) {
    const reply = await redis.set(`l402:spent:${paymentHash}`, "1", {
      condition: "NX",
      expiration: { type: "EX", value: ttlSeconds },
    });
    return reply === "OK";
  },
};

app.use("/api/premium", l402.paywall(ln, { walletId, price: 10, spentStore }));
typescript

In ASP.NET Core, register an ISpentTokenStore in DI and every paywall uses it.

For a pass rather than a single request, set reuse: true: the token then works on any route under that paywall until it expires. Set expirySeconds with it, since a reusable token without one never expires.

// ₿500 for an hour of /api/pass, as many requests as the holder makes
app.use("/api/pass", l402.paywall(ln, { walletId, price: 500, reuse: true, expirySeconds: 3600 }));
typescript

The middleware checks a token's route, price and spent status itself, and ln.bot checks its signature, preimage and expiry: the middleware calls api.ln.bot to create each challenge (createChallenge) and to verify each paid request (verify). That adds one HTTPS round trip to every request on a paid route, and while api.ln.bot can't be reached those routes answer with a server error. x402 makes a server depend on a facilitator to verify and settle payments; this setup makes it depend on ln.bot in the same way.

Test it with curl

Start the server with LNBOT_API_KEY and LNBOT_WALLET_ID set, then call the paid route without a token:

curl -i http://localhost:3000/api/premium/weather
bash
HTTP/1.1 402 Payment Required
X-Powered-By: Express
WWW-Authenticate: L402 macaroon="AgELbG4uYm90...", invoice="lnbc100n1p..."
Content-Type: application/json; charset=utf-8
...

The body, formatted:

{
  "type": "payment_required",
  "title": "Payment Required",
  "detail": "Pay the included Lightning invoice to access this resource.",
  "invoice": "lnbc100n1p...",
  "macaroon": "AgELbG4uYm90...",
  "price": 10,
  "unit": "satoshis",
  "description": "Weather API",
  "reusable": false,
  "expiresAt": "2026-10-23T22:43:28.127Z"
}
json

lnbc100n is an invoice for ₿10 (100 nanobitcoin). "reusable": false tells clients the token works once, so they don't cache it. Any Lightning wallet can pay the invoice; the retry then carries Authorization: L402 <macaroon>:<preimage>, where the preimage is the payment's proof.

To pay from code, use a second wallet (lnbot wallet create makes one) and fund it with ₿100 or so by paying its address from any Lightning wallet. l402.client does the whole exchange (its options):

import { l402, LnBot } from "@lnbot/l402";

const buyer = new LnBot({ apiKey: process.env.BUYER_API_KEY }); // the second wallet's wk_ key
const api = l402.client(buyer, { walletId: process.env.BUYER_WALLET_ID!, maxPrice: 50 });

console.log(await api.get("http://localhost:3000/api/premium/weather"));
typescript

Every api.get pays ₿10, because the client doesn't keep single-use tokens. To watch a token get refused the second time, pay a challenge yourself and send the same token twice:

import { LnBot } from "@lnbot/sdk";

const url = "http://localhost:3000/api/premium/weather";
const w = new LnBot({ apiKey: process.env.BUYER_API_KEY }).wallet(process.env.BUYER_WALLET_ID!);

const challenge = (await fetch(url)).headers.get("www-authenticate")!;
const { authorization } = await w.l402.pay({ wwwAuthenticate: challenge });
const headers = { Authorization: authorization! };

console.log((await fetch(url, { headers })).status); // 200
console.log((await fetch(url, { headers })).status); // 402: spent, with a new challenge
typescript

In .NET, AddL402Handler(new L402ClientOptions { WalletId = "wal_...", MaxPrice = 50 }) on a named HttpClient makes the same checks and pays the same way.

What you receive

Each paid request settles an invoice in the wallet whose ID the paywall was given, for the full price: receiving costs nothing. lnbot balance or w.get() shows the total, and lnbot transactions or w.transactions.list() shows each credit. Moving the money to another ln.bot wallet is free; to any other Lightning wallet it costs 0.25%, minimum ₿1, plus the network fee: another 0.25% and the routing fee.

Prices are in bitcoin's smallest unit, so their dollar value moves with the bitcoin price: at about $81,000 per bitcoin (CoinGecko, October 23, 2026), ₿10 is $0.0081. For comparison, Stripe's standard fee for US cards is "2.9% + 30¢ per successful transaction" (Stripe pricing, as of October 2026), which is about thirty times the price of a one-cent charge.

FAQ

How do I charge per API request with Bitcoin?

Put L402 middleware in front of the route: l402.paywall for Express or UseL402Paywall for ASP.NET Core. A request without a valid token gets HTTP 402 with a Lightning invoice. The client pays it, retries with proof of payment, and the request reaches your handler.

Can a client reuse an L402 token?

Not by default in @lnbot/l402 and LnBot.L402 1.1. A token works once, only on the route and at the price it was issued for, and expires after an hour. Set reuse to true for a pass that works until it expires. If the API runs on several instances, give them one shared spentStore.

What does it cost to accept L402 payments with ln.bot?

Receiving is free, and so are transfers to other ln.bot wallets. Paying out to another Lightning wallet costs 0.25%, minimum ₿1, plus the network fee: another 0.25% and the routing fee. There is no subscription.

Do API consumers need an account?

Not with you. They need a Lightning wallet that can pay the invoice. With an ln.bot wallet, l402.client pays and retries for them; with another wallet they pay the invoice and send its preimage back with the macaroon.

Does the paywall depend on ln.bot?

Yes. The middleware calls api.ln.bot to create every challenge and to verify every paid request, so paid routes only work while api.ln.bot is reachable.