Skip to content

About

L402 Lightning payment middleware for .NET — paywall any ASP.NET Core API in one line

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

8 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

LnBot.L402

NuGet NuGet License: MIT

L402 Lightning payment middleware for .NET — paywall any API in one line. Built on ln.bot.

Two NuGet packages:

  • LnBot.L402 — Client-side. Auto-pay L402-protected APIs with any HttpClient. Works in console apps, background services, MAUI — anything with HttpClient.
  • LnBot.L402.AspNetCore — Server-side. Protect ASP.NET Core routes behind L402 paywalls with middleware, [L402] attributes, or endpoint filters.

Both packages are thin glue layers. All L402 logic — macaroon creation, signature verification, preimage checking — lives in the ln.bot API via the LnBot SDK. Zero crypto dependencies.

By default each payment buys one request: a token works once, on the route and at the price it was issued for. Set Reuse = true for a pass that works until it expires.


What is L402?

L402 is a protocol built on HTTP 402 Payment Required. It enables machine-to-machine micropayments over the Lightning Network:

  1. Client requests a protected resource
  2. Server returns 402 with a Lightning invoice and a macaroon token
  3. Client pays the invoice, obtains the preimage as proof of payment
  4. Client retries the request with Authorization: L402 <macaroon>:<preimage>
  5. Server verifies the token and grants access

L402 is ideal for API monetization, AI agent tool access, pay-per-request data feeds, and any scenario where you want instant, permissionless, per-request payments without subscriptions or API key provisioning.


Install

dotnet add package LnBot.L402.AspNetCore   # Server (includes client package)
dotnet add package LnBot.L402              # Client only (no ASP.NET Core dependency)

Server — Protect Routes with L402

Middleware pipeline

using LnBot;
using LnBot.L402;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(new LnBotClient("uk_..."));

var app = builder.Build();

app.UseL402Paywall("/api/premium", new L402Options
{
    WalletId = "wal_...",
    Price = 10,
    Description = "API access",
});

app.MapGet("/api/premium/data", (HttpContext context) =>
{
    var l402 = context.GetL402();
    return Results.Ok(new { data = "premium content", paymentHash = l402?.PaymentHash });
});
app.MapGet("/api/free/health", () => Results.Ok(new { status = "ok" }));

app.Run();

Controller attribute

[ApiController]
[Route("api/[controller]")]
public class WeatherController : ControllerBase
{
    [L402(WalletId = "wal_...", Price = 50, Description = "Weather forecast", ExpirySeconds = 3600, Caveats = new[] { "tier=pro" })]
    [HttpGet("forecast")]
    public IActionResult GetForecast()
        => Ok(new { forecast = "sunny" });
}

Minimal API endpoint filter

app.MapGet("/api/premium/data", () => Results.Ok(new { data = "premium" }))
   .AddEndpointFilter(new L402EndpointFilter("wal_...", price: 10, description: "API access"));

Dynamic pricing

app.UseL402Paywall("/api/dynamic", new L402Options
{
    WalletId = "wal_...",
    PriceFactory = context =>
    {
        if (context.Request.Path.StartsWithSegments("/api/dynamic/bulk"))
            return Task.FromResult(50);
        return Task.FromResult(5);
    }
});

Paywall options

Option Type Description
WalletId string Wallet ID for L402 operations
Price int Price in satoshis per request
PriceFactory Func<HttpContext, Task<int>>? Dynamic pricing callback (takes precedence over Price)
Description string? Invoice memo shown in wallets
ExpirySeconds int? Token lifetime in seconds. Default 3600; with Reuse, no expiry unless set
Caveats List<string>? Extra macaroon caveats (at most 8; two more carry each token's binding)
Reuse bool false (default): one token, one request. true: a pass for this paywall that works until it expires
SpentStore ISpentTokenStore? Where spent tokens are remembered. Default: one from DI, else in memory

One token, one request

By default each payment buys one request. Every challenge carries two caveats, lnbot_path=<path> and lnbot_price=<sats>, so the token it produces only opens the route it was issued for, at that price or less. A holder can add caveats to a macaroon but never remove them, so a token can't be widened. Tokens expire after an hour unless you set ExpirySeconds, and a spent token is remembered until then.

Spent tokens are remembered in memory by default. If your API runs on more than one instance, register one ISpentTokenStore they all share, or a token spent on one instance could be spent again on another:

using StackExchange.Redis;

public sealed class RedisSpentTokenStore(IConnectionMultiplexer redis) : ISpentTokenStore
{
    // SET NX with an expiry: true only for the first caller
    public Task<bool> ClaimAsync(string paymentHash, TimeSpan ttl, CancellationToken cancellationToken = default) =>
        redis.GetDatabase().StringSetAsync($"l402:spent:{paymentHash}", "1", ttl, When.NotExists);
}

builder.Services.AddSingleton<IConnectionMultiplexer>(ConnectionMultiplexer.Connect("localhost"));
builder.Services.AddSingleton<ISpentTokenStore, RedisSpentTokenStore>();

A token is spent when the request reaches your endpoint, even if the endpoint then fails.

Passes: Reuse = true

For "pay once, use for an hour", let tokens be reused until they expire. A pass carries lnbot_pass=<prefix> and lnbot_price=<sats> caveats instead: it opens any route under the paywall's path prefix (or the endpoint's route, for the attribute and filter), at that price or less. Single-use tokens don't open a pass route, and passes don't open single-use routes:

app.UseL402Paywall("/api/pass", new L402Options
{
    WalletId = "wal_...",
    Price = 500,
    Reuse = true,
    ExpirySeconds = 3600, // without it, a reusable token never expires
});

The [L402] attribute takes Reuse = true too, and L402EndpointFilter takes reuse: true.

Accessing L402 data after verification

After a successful paywall check, use the GetL402() extension method to access the verification result:

app.MapGet("/api/premium/data", (HttpContext context) =>
{
    var l402 = context.GetL402();
    return Results.Ok(new
    {
        data = "premium content",
        paymentHash = l402?.PaymentHash,
        caveats = l402?.Caveats,
    });
});

Client — Auto-Pay L402 APIs

With ASP.NET Core DI

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(new LnBotClient("uk_..."));

builder.Services.AddHttpClient("paid-apis")
    .AddL402Handler(new L402ClientOptions
    {
        WalletId = "wal_...",
        MaxPrice = 100,
        BudgetSats = 50_000,
        BudgetPeriod = BudgetPeriod.Day,
    });

var app = builder.Build();

app.MapGet("/proxy", async (IHttpClientFactory factory) =>
{
    var http = factory.CreateClient("paid-apis");
    var data = await http.GetStringAsync("https://api.example.com/premium/data");
    return Results.Ok(data);
});

Console app (no ASP.NET Core)

using LnBot;
using LnBot.L402;
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddSingleton(new LnBotClient("uk_..."));
services.AddSingleton<ITokenStore, MemoryTokenStore>();
services.AddHttpClient("paid-apis").AddL402Handler(new L402ClientOptions { WalletId = "wal_..." });

var provider = services.BuildServiceProvider();
var http = provider.GetRequiredService<IHttpClientFactory>().CreateClient("paid-apis");

// Auto-pays any 402 responses transparently
var response = await http.GetStringAsync("https://api.example.com/premium/data");
Console.WriteLine(response);

Header Utilities

using LnBot.L402;

// Parse Authorization: L402 <macaroon>:<preimage>
var auth = L402Headers.ParseAuthorization("L402 mac_base64:preimage_hex");
// → (Macaroon: "mac_base64", Preimage: "preimage_hex")

// Parse WWW-Authenticate: L402 macaroon="...", invoice="..."
var challenge = L402Headers.ParseChallenge("L402 macaroon=\"abc\", invoice=\"lnbc1...\"");
// → (Macaroon: "abc", Invoice: "lnbc1...")

// Format headers
L402Headers.FormatAuthorization("mac", "pre");     // → "L402 mac:pre"
L402Headers.FormatChallenge("mac", "lnbc1...");    // → "L402 macaroon=\"mac\", invoice=\"lnbc1...\""

Custom Token Store

Implement ITokenStore for Redis, file system, or any persistence layer:

public class RedisTokenStore : ITokenStore
{
    public Task<L402Token?> GetAsync(string url) { /* ... */ }
    public Task SetAsync(string url, L402Token token) { /* ... */ }
    public Task DeleteAsync(string url) { /* ... */ }
}

// Register in DI
services.AddSingleton<ITokenStore, RedisTokenStore>();

How It Works

Server middleware makes two SDK calls:

  • client.Wallet(walletId).L402.CreateChallengeAsync() — creates an invoice + macaroon when a client needs to pay
  • client.Wallet(walletId).L402.VerifyAsync() — verifies an L402 authorization token when a client presents one

Then it checks that the token was issued for this route and price, and marks it spent (unless Reuse is set).

Client handler makes one SDK call:

  • client.Wallet(walletId).L402.PayAsync() — pays a Lightning invoice and returns a ready-to-use Authorization header

Before paying, it reads the amount from the invoice itself and checks it against MaxPrice and the budget. What the server says its price is doesn't count: the invoice is what gets paid, and an invoice without an amount is refused. It caches a token only if the server marks it reusable. The budget is kept in memory, so it starts again from zero when the process restarts; to cap what an agent can spend in total, fund its wallet with only that much.


Requirements

Related packages

Links

License

MIT

About

L402 Lightning payment middleware for .NET — paywall any ASP.NET Core API in one line

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages