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.
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:
- Reads
Authorization: L402 <macaroon>:<preimage>from each request - If the matched endpoint has
[L402]and no valid credential → mints a fresh challenge via Lightning Enable's hosted producer API and returns402 Payment Required - If a valid credential is present → executes the action
Endpoints without [L402] pass through ungated.
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:
PriceSelector— consulted first if set. Returning a non-null value decides the price for that request. Returningnullmeans "no opinion" and falls through to the next step — it does not ungate the request.[L402(PriceSats = N)]attribute on the matched endpointDefaultPriceSatson options- 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:
| Property | Type | Notes |
|---|---|---|
PriceSats | int (required) | Price in satoshis, ≥ 1. Compile-time constant — for variable pricing use PriceSelector on the options. |
Description | string? | Embedded in the Lightning invoice; shown in the payer's wallet UI. |
Resource | string? | 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:
Resourceon the matched[L402]attributeResourceSelectoron the middleware optionsHttpContext.Request.Path(the default)
Configuration reference
L402AspNetCoreOptions:
| Option | Type | Default | Notes |
|---|---|---|---|
DefaultPriceSats | int? | null | Flat price applied when no [L402] attribute is present |
PriceSelector | Func<HttpContext, ValueTask<int?>>? | null | Variable pricing per request. A non-null return wins; null falls through to the [L402] attribute, then DefaultPriceSats |
ResourceSelector | Func<HttpContext, string>? | HttpContext.Request.Path | Bound as a macaroon caveat |
DescriptionSelector | Func<HttpContext, string?>? | null | Shown in the payer's Lightning wallet |
IdempotencyKeySelector | Func<HttpContext, string?>? | null | Sends X-Idempotency-Key for retry-safe challenge minting |
OnInvalidToken | Func<HttpContext, VerificationResult, ValueTask>? | sends 401 | Custom 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_KEYis 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
- Producer API reference — full HTTP surface
- Native Integration — Express — Node + Express version of this walkthrough
- Proxy setup walkthrough — if Proxy mode is a better fit