Skip to main content

Native L402 Integration — ASP.NET Core

This walkthrough takes you from a vanilla ASP.NET Core app to charging Lightning payments per request in under 10 minutes. You'll install L402Server.AspNetCore, add one line of middleware + an attribute, and your existing endpoints become paid endpoints.

If you haven't picked an integration mode yet, start with the Native Integration overview to understand when Native mode is the right fit.

Runnable example

A complete, working ASP.NET Core integration lives at l402-example-aspnet — clone it, set your API key, and curl a live 402 → pay → 200 flow before wiring your own app.

Prerequisites

  • .NET 8.0 or higher
  • An ASP.NET Core app
  • A Lightning Enable merchant API key — generate at Dashboard → Settings → API Keys
  • A payment provider (Strike or OpenNode) configured under Dashboard → Settings → Payment Provider

Install

dotnet add package L402Server.AspNetCore

(L402Server — the underlying SDK — is pulled in transitively. Both are MIT-licensed.)

30-second example

using L402Server.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddL402AspNetCore(opts =>
{
opts.ApiKey = builder.Configuration["LightningEnable:ApiKey"]!;
});

var app = builder.Build();

app.UseRouting();
app.UseL402();
app.MapControllers();
app.Run();

Then mark any controller action with [L402(PriceSats = N)]:

[ApiController]
[Route("api/premium")]
public class PremiumController : ControllerBase
{
[HttpGet("weather")]
[L402(PriceSats = 100)]
public IActionResult Weather() => Ok(new { temp = 72 });
}

That's the whole integration. The middleware:

  1. Reads Authorization: L402 <macaroon>:<preimage> from each request
  2. If the matched endpoint has [L402] and no valid credential → mints a fresh challenge via Lightning Enable's hosted producer API and returns 402 Payment Required
  3. If a valid credential is present → executes the action

Endpoints without [L402] pass through ungated.

L402Server.AspNetCore 0.1.x verifies macaroon + preimage only — assert the resource yourself when gating multiple tiers

Current versions (L402Server.AspNetCore 0.1.x, L402Server 0.1.x) call the verify endpoint with only the macaroon and preimage — they do not pass or compare the request path or amount. Merchant binding and expiry are always enforced by the hosted API, but a token bought for one of your endpoints will also pass verification on a different endpoint gated under the same API key, within the token's validity window (60 minutes by default).

If you gate multiple prices or resources under one API key, add a resource assertion in your action:

[HttpGet("weather"), L402(PriceSats = 100)]
public IActionResult Weather()
{
var result = (VerificationResult)HttpContext.Items[L402HttpContextKeys.VerificationResult]!;
if (result.Resource != HttpContext.Request.Path)
{
return Unauthorized(new { error = "Token was purchased for a different resource" });
}
return Ok(new { temp = 72 });
}

An SDK update that passes the request path through to the verify call automatically is in progress; this applies to the 0.1.x releases. A single flat price across your gated surface is unaffected in practice — every token you issue is for the same class of access.

What the caller sees

Without payment

curl -i https://your-api.example/api/premium/weather
HTTP/1.1 402 Payment Required
Content-Type: application/json
WWW-Authenticate: L402 macaroon="AgELbWFjYXJvb24...", invoice="lnbc1u1p3..."

{
"error": "Payment Required",
"l402": {
"macaroon": "AgELbWFjYXJvb24...",
"invoice": "lnbc1u1p3...",
"amount_sats": 100,
"payment_hash": "abc123...",
"expires_at": "2026-05-12T01:00:00Z",
"resource": "/api/premium/weather"
}
}

With payment

curl -i https://your-api.example/api/premium/weather \
-H 'Authorization: L402 AgELbWFjYXJvb24...:deadbeef...'
HTTP/1.1 200 OK
Content-Type: application/json

{ "temp": 72 }

Pricing patterns

Per-route attributes (compile-time prices)

[HttpGet("forecast"), L402(PriceSats = 100)]
public IActionResult Forecast() => Ok(...);

[HttpGet("premium-llm"), L402(PriceSats = 500, Description = "GPT-4 backed")]
public IActionResult PremiumLlm() => Ok(...);

Global flat price (gates everything mounted under the middleware)

app.UseL402(opts => opts.DefaultPriceSats = 100);

Function-form pricing (variable per request)

PriceSelector is a Func<HttpContext, ValueTask<int?>>? — note the nullable int?:

app.UseL402(opts =>
{
opts.PriceSelector = ctx => ValueTask.FromResult<int?>(
ctx.Request.Query["model"] == "premium" ? 500 : 100);
});

Resolution order:

  1. PriceSelector — consulted first if set. Returning a non-null value decides the price for that request. Returning null means "no opinion" and falls through to the next step — it does not ungate the request.
  2. [L402(PriceSats = N)] attribute on the matched endpoint
  3. DefaultPriceSats on options
  4. All three unset/null → request passes through ungated

This lets you use PriceSelector for just the requests it recognizes (say, a surge-priced model parameter) while attributes and DefaultPriceSats cover everything else.

The [L402] attribute in full

L402Attribute has three properties and can target methods or classes:

PropertyTypeNotes
PriceSatsint (required)Price in satoshis, ≥ 1. Compile-time constant — for variable pricing use PriceSelector on the options.
Descriptionstring?Embedded in the Lightning invoice; shown in the payer's wallet UI.
Resourcestring?Optional override for the resource bound into the macaroon's path caveat. Defaults to the request path when omitted.

Class-level gating

Apply [L402] to a controller class to gate every action on it at one price:

[ApiController]
[Route("api/premium")]
[L402(PriceSats = 100)] // every action on this controller costs 100 sats
public class PremiumController : ControllerBase
{
[HttpGet("weather")]
public IActionResult Weather() => Ok(new { temp = 72 });

[HttpGet("forecast")]
public IActionResult Forecast() => Ok(new { days = 7 });
}

A method-level [L402] on an action takes precedence over a class-level one for that action.

Resource override

[HttpGet("weather")]
[L402(PriceSats = 100, Resource = "/api/premium/weather-v1")]
public IActionResult Weather() => Ok(new { temp = 72 });

The resource bound into the challenge resolves in this order:

  1. Resource on the matched [L402] attribute
  2. ResourceSelector on the middleware options
  3. HttpContext.Request.Path (the default)

Configuration reference

L402AspNetCoreOptions:

OptionTypeDefaultNotes
DefaultPriceSatsint?nullFlat price applied when no [L402] attribute is present
PriceSelectorFunc<HttpContext, ValueTask<int?>>?nullVariable pricing per request. A non-null return wins; null falls through to the [L402] attribute, then DefaultPriceSats
ResourceSelectorFunc<HttpContext, string>?HttpContext.Request.PathBound as a macaroon caveat
DescriptionSelectorFunc<HttpContext, string?>?nullShown in the payer's Lightning wallet
IdempotencyKeySelectorFunc<HttpContext, string?>?nullSends X-Idempotency-Key for retry-safe challenge minting
OnInvalidTokenFunc<HttpContext, VerificationResult, ValueTask>?sends 401Custom handler

Accessing the verified credential in your action

After a successful verification the middleware sets HttpContext.Items[L402HttpContextKeys.VerificationResult]. In a controller, read it via the HttpContext property that ControllerBase exposes (MVC does not bind HttpContext as an action parameter — a Weather(HttpContext ctx) signature won't be populated):

[HttpGet("weather"), L402(PriceSats = 100)]
public IActionResult Weather()
{
var result = (VerificationResult)HttpContext.Items[L402HttpContextKeys.VerificationResult]!;
_logger.LogInformation(
"Served {Resource} for {Sats} sats ({Hash})",
result.Resource, result.AmountSats, result.PaymentHash);
return Ok(new { temp = 72 });
}

(In minimal APIs, an HttpContext handler parameter is injected — the property route is only needed inside controllers.)

Useful for usage logging, per-endpoint analytics, fraud detection.

Minimal API support

[L402] is a regular Attribute so it works on minimal-API metadata too:

app.MapGet("/api/premium/weather", () => new { temp = 72 })
.WithMetadata(new L402Attribute { PriceSats = 100 });

Using the SDK directly (without the middleware)

If you need to mint a challenge or verify a token outside the request pipeline — from a background service, a hosted worker, an HTTP handler in a non-ASP.NET context — use L402Server directly:

using L402Server;

var client = new L402ServerClient(new L402ServerOptions
{
ApiKey = Environment.GetEnvironmentVariable("LIGHTNING_ENABLE_API_KEY")!,
});

var challenge = await client.CreateChallengeAsync(new CreateChallengeRequest
{
Resource = "/api/x",
PriceSats = 100,
});

var verification = await client.VerifyTokenAsync(new VerifyTokenRequest
{
Macaroon = mac,
Preimage = pre,
});

Pipeline order

The middleware needs to be placed AFTER UseRouting() (so it can read [L402] attribute metadata from the matched endpoint) and BEFORE the endpoint executor (MapControllers, UseEndpoints, etc.):

app.UseRouting();
app.UseAuthentication(); // any other auth middleware
app.UseAuthorization();
app.UseL402(); // ← here
app.MapControllers();

If you put UseL402() before UseRouting() the middleware will see no matched endpoint and won't find [L402] attributes. If you put it after MapControllers() it'll never run.

Custom failure handling

Default behavior: invalid L402 token → 401 Unauthorized. Override with OnInvalidToken to send a fresh 402 instead:

app.UseL402(opts =>
{
opts.OnInvalidToken = async (ctx, failure) =>
{
ctx.Response.StatusCode = 402;
await ctx.Response.WriteAsJsonAsync(new
{
error = "Token rejected — please pay again",
details = failure.Error,
});
};
});

When OnInvalidToken is supplied, the middleware does NOT send the default 401 and does NOT continue to the next middleware — your callback is fully responsible for producing the response. Write a status code + body via ctx.Response, or redirect via ctx.Response.Redirect(...). The pipeline short-circuits after your callback returns. (The callback signature is Func<HttpContext, VerificationResult, ValueTask> — there's no next delegate passed in.)

Troubleshooting

502 Bad Gateway on every request

The middleware couldn't reach Lightning Enable. Check:

  • LIGHTNING_ENABLE_API_KEY is set and valid
  • No outbound firewall blocking api.lightningenable.com
  • Subscription is active

Every request returns 402 even after payment

Behind a reverse proxy? Confirm the Authorization header is being forwarded to the ASP.NET Core app. In Azure App Service and most reverse-proxy setups this just works; some custom proxies strip the header.

403 Forbidden from upstream

L402 isn't enabled on your subscription plan. Check Dashboard → Settings → Plan — Native mode requires Agentic Commerce — Individual or Business.

[L402] attributes don't seem to work

Verify app.UseL402() is placed AFTER app.UseRouting() and BEFORE app.MapControllers() / app.UseEndpoints(). The middleware reads attribute metadata from the matched endpoint, which only exists after routing has run.

Source and license

Both packages are MIT-licensed open source:

Next steps