Skip to main content

L402 Producer API Reference

The producer API is two HTTP endpoints. Everything an integrator needs is on this page. If you're on Node or .NET, the SDKs and middleware wrap these endpoints with idiomatic ergonomics — you can use them instead. This page is for direct integrators on stacks we don't ship a package for yet, or for understanding what the SDK is doing under the hood.

Base URL

https://api.lightningenable.com

Authentication

Every request requires your Lightning Enable merchant API key in the X-API-Key header. Generate one at Dashboard → Settings → API Keys.

X-API-Key: <your-merchant-api-key>

The key is tied to your merchant account and to an Agentic Commerce subscription (Individual $99/mo or Business $299/mo). L402 must be enabled on your plan — Native mode is included with both Agentic Commerce tiers.

POST /api/l402/challenges

Mint a Lightning invoice and macaroon for a given resource. Returns the components of a 402 Payment Required challenge that you present to the caller.

Request

POST /api/l402/challenges HTTP/1.1
Host: api.lightningenable.com
X-API-Key: <your-merchant-api-key>
Content-Type: application/json
X-Idempotency-Key: <optional, retry-safe key>

{
"resource": "/api/premium/weather",
"priceSats": 100,
"description": "Premium weather forecast"
}

Body:

FieldTypeRequiredNotes
resourcestringyesThe path/resource the challenge is for. Bound as a caveat in the macaroon — the resulting token is only valid for this resource.
priceSatsinteger (≥ 1)yesPrice in satoshis.
descriptionstringnoEmbedded in the Lightning invoice; visible to the payer in their wallet UI.

Headers:

HeaderRequiredNotes
X-API-KeyyesMerchant API key
Content-Typeyesapplication/json
X-Idempotency-KeynoIf supplied, the same challenge is returned for repeat calls with the same key within the invoice's expiry window. Useful for retry-safe middleware. Server truncates to 256 chars. If omitted, the server falls back to the client IP for deduplication.

Response — 200 OK

{
"invoice": "lnbc1u1p3...",
"macaroon": "AgELbWFjYXJvb24=...",
"paymentHash": "abc123...",
"expiresAt": "2026-05-12T01:00:00Z",
"resource": "/api/premium/weather",
"priceSats": 100,
"mppChallenge": null
}
FieldTypeNotes
invoicestringBOLT11 Lightning invoice the caller must pay
macaroonstringURL-safe base64 (base64url) macaroon containing the payment hash and caveats. Uses -/_ instead of +// and may omit padding — decode with a base64url-aware function (base64.urlsafe_b64decode in Python, Buffer.from(s, 'base64url') in Node, WebEncoders.Base64UrlDecode in .NET) rather than standard base64.
paymentHashstringHex payment hash linking the macaroon to the invoice
expiresAtstring (ISO 8601)When the Lightning invoice expires
resourcestringEchoes the request's resource
priceSatsintegerEchoes the request's priceSats
mppChallengestring | nullMPP-formatted WWW-Authenticate value, present only if MPP is enabled on the producer API

Error responses

StatusMeaning
400 Bad RequestValidation error. Two body shapes are possible: data-annotation failures (missing resource, priceSats < 1, etc.) return RFC 7807 ProblemDetails ({ "type", "title", "errors": { ... } }) from ASP.NET Core's model-state filter; business-logic errors from IL402Service return the simpler { "error": "..." } shape. Handle both by checking the response Content-Type or by attempting error first, falling back to title / errors.
401 UnauthorizedMissing, malformed, or revoked X-API-Key. Body: { "error": "..." }.
403 ForbiddenL402 is not enabled on your plan. Body includes current_plan and action_required: "upgrade_plan".
429 Too Many RequestsRate-limited.
5xxProducer API or upstream wallet error. Body: { "error": "..." }.

POST /api/l402/challenges/verify

Verify an L402 credential — a macaroon + preimage pair presented in an Authorization: L402 header from a caller who paid your challenge.

Request

POST /api/l402/challenges/verify HTTP/1.1
Host: api.lightningenable.com
X-API-Key: <your-merchant-api-key>
Content-Type: application/json

{
"macaroon": "AgELbWFjYXJvb24=...",
"preimage": "deadbeef..."
}

Body:

FieldTypeRequiredNotes
macaroonstringrequired for L402The URL-safe base64 (base64url) macaroon from the caller's Authorization header — pass through unchanged, no re-encoding. Omit only if doing MPP-style preimage-only verification (and MPP is enabled on your account).
preimagestring (hex, 64 chars)yesThe payment preimage proving the invoice was paid.
resourcestring | nullrecommendedThe path the caller is gating. If you provide it, the producer API enforces the macaroon's path caveat against this value — a mismatch returns valid: false. If you omit it, the path caveat is read out but not enforced (the integrator is responsible for the comparison).
amountSatsinteger | nullrecommendedThe price tier the gated endpoint requires. If you provide it, the producer API enforces the macaroon's amount_sats caveat against this value — prevents replaying a cheap token against an expensive endpoint matched by a wildcard rule. If you omit it, the amount caveat is read out but not enforced.
Defense in depth

Two enforcement guarantees are always applied server-side regardless of which optional fields you pass:

  • Authenticated merchant_id is always compared to the macaroon's merchant_id caveat. Calling the verify endpoint as merchant B with a token bound to merchant A returns valid: false. There is no opt-out — this is the cross-tenant IDOR guard.
  • Macaroon signature, preimage hash, and expires caveat are always verified.

The optional resource and amountSats fields opt you into additional path/amount caveat enforcement. Pass them whenever you have the values handy; the only reason to skip is a generic verifier that doesn't know the gated path up front.

Response — 200 OK

The producer API returns 200 OK for both valid and invalid tokens — read the valid field rather than relying on the status code.

Valid token:

{
"valid": true,
"resource": "/api/premium/weather",
"merchantId": 42,
"amountSats": 100,
"paymentHash": "abc123..."
}

Invalid token:

{
"valid": false,
"error": "Invalid preimage"
}
FieldTypeNotes
validboolThe gate. Inspect this.
errorstring | nullFailure reason; only populated when valid: false. Examples: "Invalid preimage", "Token bound to a different resource", "Macaroon signature invalid".
resourcestring | nullThe path/resource the token is bound to (from the macaroon's caveat). Assert this matches the resource the caller is actually requesting.
merchantIdinteger | nullThe merchant ID the macaroon was issued under.
amountSatsinteger | nullThe amount the token was issued for.
paymentHashstring | nullThe payment hash from the macaroon's identifier.

Error responses

StatusMeaning
400 Bad RequestValidation (missing preimage, MPP-only but MPP not enabled, etc.)
401 UnauthorizedMissing or invalid X-API-Key
403 ForbiddenL402 not enabled on your plan

Note: a valid macaroon with an invalid preimage still returns 200 OK with valid: false. Non-2xx is reserved for auth / plan / transport problems.


Token reuse within the validity window

L402 tokens remain valid for repeated verifications until the macaroon's expires caveat passes. The default is 60 minutes from issuance (L402Options.DefaultTokenValiditySeconds = 3600). During that window the producer API returns valid: true for any verification of a valid macaroon + preimage pair, including replays of the same pair. This is intentional, not a bug.

Two separate durations to understand:

  • Token validity (60 min default) — controlled by DefaultTokenValiditySeconds, embedded as an expires caveat in the macaroon. This is the window during which a paid token can be re-presented and verified successfully.
  • Invoice expiry (10 min default) — controlled by InvoiceExpirySeconds. This is the window during which the Lightning invoice itself can be paid. After this, the invoice is dead and the macaroon is moot regardless of its expiry caveat.

Caveat enforcement on POST /api/l402/challenges/verify:

  • merchant_id caveatalways enforced against the authenticated merchant id (derived from your X-API-Key). Merchant A cannot verify a macaroon that was bound to merchant B. No opt-out.
  • path caveatenforced when you pass resource in the verify request body. Without resource, the path caveat is reported in the response (resource field) but not compared — the integrator is responsible for the check.
  • amount caveatenforced when you pass amountSats in the verify request body. Without amountSats, the amount is reported in the response but not compared.
  • expires caveat — always enforced. A token presented after its validity window returns valid: false.

Pre-2026-05 (before the verify endpoint switched to context-aware verification), path and amount caveats were always read out but never compared; integrators had to do the comparison themselves. They still can, but passing resource / amountSats on the request now opts into stricter server-side checks. New integrations should pass them; existing integrations that already do client-side comparison can omit them without breaking anything.

Caveats do NOT prevent same-resource reuse within the validity window. That's by design: a paid agent making many quick calls within one paid window is a legitimate use case, and the burden of caching preimages on the consumer side is real (the open-source l402-requests clients don't do it by default).

If you specifically need single-use semantics for a particular endpoint (e.g., a one-shot model that returns expensive state), track consumed preimages locally in your handler. A trivial in-memory set keyed on paymentHash works for single-process apps; Redis or your existing database works for distributed deployments. The verification result includes paymentHash precisely so you can do this without re-parsing the macaroon.


Idempotency

POST /api/l402/challenges is idempotent within the invoice's expiry window when X-Idempotency-Key is supplied:

POST /api/l402/challenges
X-Idempotency-Key: req-abc-123
{...}

The dedup key is (merchantId, clientIdentifier, resource, priceSats) — where clientIdentifier is the X-Idempotency-Key value if supplied or the client IP if not. The description field is not part of the key, so changing only the description will still replay the cached challenge. After the invoice expires (10 minutes default — set by L402Options.InvoiceExpirySeconds), a fresh challenge is minted on the next call.

Without X-Idempotency-Key, the server uses the client IP as the clientIdentifier. That's usually correct for middleware running on a single server, but not for distributed deployments behind a load balancer — pass an explicit key in those cases.

POST /api/l402/challenges/verify is a stateless read-only check on the macaroon + preimage. Repeated verification of the same (macaroon, preimage) pair during the token validity window returns the same valid: true result every time — see Token reuse within the validity window above for the semantics.


End-to-end example flows

Mint + present a challenge

# 1. Caller requests your endpoint without paying
$ curl -i https://your-api.example/api/premium/weather
HTTP/1.1 402 Payment Required
WWW-Authenticate: L402 macaroon="AgEL...", invoice="lnbc1u..."
Content-Type: application/json

{ "error": "Payment Required", "l402": { ... } }

In your handler before responding with that 402, you called:

$ curl -X POST https://api.lightningenable.com/api/l402/challenges \
-H 'X-API-Key: $LIGHTNING_ENABLE_API_KEY' \
-H 'Content-Type: application/json' \
-d '{"resource":"/api/premium/weather","priceSats":100}'

{
"invoice": "lnbc1u1p3...",
"macaroon": "AgEL...",
"paymentHash": "abc123",
"expiresAt": "2026-05-12T01:00:00Z",
"resource": "/api/premium/weather",
"priceSats": 100
}

Verify a returning request

# Caller pays the invoice, gets the preimage, retries with credential:
$ curl -i https://your-api.example/api/premium/weather \
-H 'Authorization: L402 AgEL...:deadbeef...'

In your handler:

$ curl -X POST https://api.lightningenable.com/api/l402/challenges/verify \
-H 'X-API-Key: $LIGHTNING_ENABLE_API_KEY' \
-H 'Content-Type: application/json' \
-d '{"macaroon":"AgEL...","preimage":"deadbeef..."}'

{
"valid": true,
"resource": "/api/premium/weather",
"merchantId": 42,
"amountSats": 100,
"paymentHash": "abc123"
}

Once you see valid: true, serve the response. Optionally assert resource matches the path the caller is requesting (defense against macaroon reuse across endpoints — though the producer API already enforces this via caveats).


Language-specific quick references

These are the minimum to call the producer API from each language. For richer ergonomics use the SDKs/middlewares.

Node.js (without the SDK)

const response = await fetch("https://api.lightningenable.com/api/l402/challenges", {
method: "POST",
headers: {
"X-API-Key": process.env.LIGHTNING_ENABLE_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
resource: "/api/premium/weather",
priceSats: 100,
}),
});
const challenge = await response.json();

Prefer the l402-server SDK or l402-express middleware.

.NET (without the SDK)

using var http = new HttpClient();
http.DefaultRequestHeaders.Add("X-API-Key", apiKey);
var body = JsonContent.Create(new { resource = "/api/premium", priceSats = 100 });
var response = await http.PostAsync("https://api.lightningenable.com/api/l402/challenges", body);
var challenge = await response.Content.ReadFromJsonAsync<Challenge>();

Prefer L402Server or L402Server.AspNetCore.

Python (no SDK yet — Phase 2 of the Native L402 roadmap)

import os
import requests

response = requests.post(
"https://api.lightningenable.com/api/l402/challenges",
headers={
"X-API-Key": os.environ["LIGHTNING_ENABLE_API_KEY"],
"Content-Type": "application/json",
},
json={"resource": "/api/premium/weather", "priceSats": 100},
)
challenge = response.json()

A Python SDK (lightningenable-l402-server) and FastAPI middleware (lightningenable-fastapi-l402) are in development.

Go (no SDK yet — Phase 2 of the Native L402 roadmap)

body, _ := json.Marshal(map[string]any{
"resource": "/api/premium/weather",
"priceSats": 100,
})
req, _ := http.NewRequest("POST", "https://api.lightningenable.com/api/l402/challenges", bytes.NewReader(body))
req.Header.Set("X-API-Key", os.Getenv("LIGHTNING_ENABLE_API_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var challenge map[string]any
json.NewDecoder(resp.Body).Decode(&challenge)

A Go SDK (github.com/refined-element/l402-server-go) with net/http middleware is on the Phase 2 roadmap.


Versioning

The producer API is stable. New optional fields may be added to request/response bodies without a version bump; breaking changes ship behind a versioned path (/api/v2/...) with overlap. Subscribe to release notes at https://docs.lightningenable.com/changelog.

Rate limits

Lightning Enable rate-limits per merchant API key. Limits are generous for typical traffic; if you're hitting them you'll see 429 Too Many Requests. Contact support if you need higher limits.

Support

Open issues at the relevant SDK/middleware repo, or contact us at support@lightningenable.com.