| Internet-Draft | Lightning Session Intent | March 2026 |
| Zhang, et al. | Expires 19 September 2026 | [Page] |
This document defines the "session" intent for the "lightning" payment method using BOLT11 invoices on the Lightning Network, within the Payment HTTP Authentication Scheme. It specifies a prepaid session model for incremental, metered payments suitable for streaming services such as LLM token generation, where the per-request cost is unknown upfront.¶
This Internet-Draft is submitted in full conformance with the provisions of BCP 78 and BCP 79.¶
Internet-Drafts are working documents of the Internet Engineering Task Force (IETF). Note that other groups may also distribute working documents as Internet-Drafts. The list of current Internet-Drafts is at https://datatracker.ietf.org/drafts/current/.¶
Internet-Drafts are draft documents valid for a maximum of six months and may be updated, replaced, or obsoleted by other documents at any time. It is inappropriate to use Internet-Drafts as reference material or to cite them other than as "work in progress."¶
This Internet-Draft will expire on 19 September 2026.¶
Copyright (c) 2026 IETF Trust and the persons identified as the document authors. All rights reserved.¶
This document is subject to BCP 78 and the IETF Trust's Legal Provisions Relating to IETF Documents (https://trustee.ietf.org/license-info) in effect on the date of publication of this document. Please review these documents carefully, as they describe your rights and restrictions with respect to this document.¶
HTTP Payment Authentication [I-D.httpauth-payment] defines a challenge-response mechanism that gates access to HTTP resources behind micropayments. This document registers the "session" intent for the "lightning" payment method.¶
Unlike the "charge" intent, which requires a full per-request Lightning payment, the session intent allows clients to pre-deposit a lump sum and then authenticate subsequent requests by presenting the payment preimage as a bearer token. The server tracks a running balance and deducts the configured cost per unit of service. When the session closes, the server refunds any unspent balance via a client-supplied return invoice.¶
This model is well-suited to streaming responses (e.g., Server-Sent Events for LLM token generation) where the total cost is unknown upfront and per-request Lightning round-trips would introduce unacceptable latency. This design is inspired by the session intent defined for EVM payment channels in [draft-tempo-session-00]. The proof mechanism differs: Lightning session uses a prepaid balance with the deposit preimage as a bearer token, while Tempo session uses cumulative vouchers against on-chain escrow. Both implementations share the same abstract intent: prepaid deposit, per-unit billing, and refund of unspent balance on close.¶
The typical session flow proceeds as follows:¶
Client requests a streaming completion (SSE response)¶
Server returns 402 with a session challenge containing a deposit invoice¶
Client pays the deposit invoice and opens a session¶
Server begins streaming; deducts cost per chunk from session balance¶
If balance is exhausted mid-stream, server emits a payment-need-topup SSE event and holds the connection open; client pays a new deposit invoice and sends a topUp credential; server resumes the stream on the same connection¶
Client closes the session; server refunds unspent balance via the return invoice provided at open time¶
Client Server Lightning Network
| | |
| (1) GET /generate | |
|--------------------------> | |
| | |
| (2) 402 + deposit invoice | |
|<-------------------------- | |
| | |
| (3) Pay deposit invoice | |
|------------------------------------------------------> |
| (4) Preimage | |
|<------------------------------------------------------ |
| | |
| (5) GET /generate | |
| action="open" | |
| preimage, returnInvoice| |
|--------------------------> | |
| | |
| (6) 200 OK + SSE stream | |
|<-------------------------- | |
| data: {chunk} | (server deducts per chunk) |
| data: {chunk} | |
| event: need-topup | (balance exhausted) |
| | |
| (7) GET /generate | |
| (unauthenticated) | |
|--------------------------> | |
| | |
| (8) 402 + deposit invoice | |
|<-------------------------- | |
| | |
| (9) Pay new invoice | |
|------------------------------------------------------> |
| (10) Preimage | |
|<------------------------------------------------------ |
| | |
| (11) GET /generate | |
| action="topUp" | |
|--------------------------> | |
| | |
| (12) 200 {"status":"ok"} | |
|<-------------------------- | |
| data: {chunk} (resumed) | |
| data: [DONE] | |
| | |
| (13) GET /generate | |
| action="close" | |
|--------------------------> | |
| | |
| | (14) Pay returnInvoice |
| |--------------------------> |
| (15) 200 {"status": | |
| "closed"} | |
|<-------------------------- | |
| | |
¶
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all capitals, as shown here.¶
A prepaid payment relationship between a client and server, identified by the paymentHash of the deposit invoice.¶
The running total of deposited satoshis minus satoshis spent against the session.¶
A [BOLT11] payment from the client to the server that establishes or extends the session balance.¶
A 32-byte random secret whose SHA-256 hash equals the paymentHash of the deposit invoice. Revealed to the payer upon Lightning payment settlement. Used as a bearer token for the lifetime of the session.¶
A BOLT11 invoice with no encoded amount, created by the client at session open. The server pays this invoice with the unspent session balance on close, specifying the refund amount explicitly via amountSatsToSend.¶
An additional Lightning payment to an existing session. The client pays a new deposit invoice and submits the top-up preimage; the server adds the amount to the session balance.¶
The intent identifier for this specification is "session". It MUST be lowercase.¶
All JSON [RFC8259] objects carried in auth-params or HTTP headers in this specification MUST be serialized using the JSON Canonicalization Scheme (JCS) [RFC8785] before encoding. JCS produces a deterministic byte sequence, which is required for any digest or signature operations defined by the base spec [I-D.httpauth-payment].¶
The resulting bytes MUST then be encoded using base64url
[RFC4648] Section 5 without padding characters
(=). Implementations MUST NOT append = padding
when encoding, and MUST accept input with or without padding when
decoding.¶
This encoding convention applies to: the request auth-param
in WWW-Authenticate, the credential token in
Authorization, and the receipt token in
Payment-Receipt.¶
A session progresses through three phases, each corresponding to one or more credential actions:¶
Open: Client pays the deposit invoice and submits an open action. The server verifies the preimage, stores session state, and begins serving requests against the deposit balance.¶
Streaming: Client submits bearer actions to authenticate requests without additional Lightning payments. The streaming layer deducts the per-unit cost from the session balance for each chunk delivered. When the balance is exhausted mid-stream, the server emits a payment-need-topup event and holds the connection open. Top-up actions extend the balance and allow the stream to resume.¶
Closed: Client submits a close action. The server verifies session ownership, pays any unspent balance to the return invoice, and marks the session closed. No further actions are accepted.¶
Sessions have no built-in expiry and remain open until explicitly closed by the client or by the server. Servers SHOULD enforce an idle timeout on open sessions to bound operational liability and storage requirements, and MUST document their timeout policy. The RECOMMENDED idle timeout is 5 minutes of inactivity. When a server closes a session without a client close action, it MUST follow the same refund procedure described in Section 12.¶
When a request arrives without a valid credential, the server MUST
respond with HTTP 402 [RFC9110], a
Cache-Control: no-store [RFC9111] header,
and a WWW-Authenticate: Payment header of the form:¶
WWW-Authenticate: Payment id="<challenge-id>", realm="<realm>", method="lightning", intent="session", request="<base64url(JCS-serialized JSON)>", expires="<RFC3339 timestamp>"¶
The request auth-param contains a JCS-serialized,
base64url-encoded JSON object (see Section 5)
with the following fields:¶
REQUIRED. Cost per unit of service in base units (satoshis), as a decimal string (e.g., "2"). For streaming responses, this is the cost per emitted chunk. The value MUST be a positive integer.¶
REQUIRED. Identifies the unit for amount. MUST be the string
"sat" (lowercase). "sat" denotes satoshis, the base unit used for
Lightning/Bitcoin amounts.¶
OPTIONAL. Human-readable description of the service. This field
is carried inside the request JSON and is distinct from
any description auth-param that the base
[I-D.httpauth-payment] scheme may include at the
header level. Servers MAY instead (or additionally) convey the
description as the base spec's description auth-param.¶
OPTIONAL. Human-readable label for the unit being priced (e.g., "token", "chunk", "request"). Informational only; clients MAY display this to users.¶
CONDITIONAL. BOLT11 invoice the client must pay to open or top up the session. REQUIRED for open and topUp challenges; MUST be absent for bearer and close challenges, where no payment is required.¶
REQUIRED. SHA-256 hash of the deposit invoice preimage, as a lowercase hex string. Used by the server to verify open and topUp credentials.¶
OPTIONAL. Exact deposit amount in satoshis, as a decimal string. When present, MUST equal the amount encoded in depositInvoice. Informs the client of the deposit size before it inspects the invoice. The BOLT11 invoice encodes a fixed amount; the client pays exactly that amount. This document uses "depositAmount" (not "suggestedDeposit") intentionally: the client cannot deposit a different amount than the invoice specifies.¶
OPTIONAL. The server's idle timeout policy for open sessions, in seconds, as a decimal string (e.g., "300" for 5 minutes). When present, informs the client how long the server will retain an open session without activity before initiating a server-side close. Clients SHOULD ensure their return invoice expiry exceeds this value. This field is informational; the server's actual timeout behavior is authoritative.¶
Servers MUST generate a fresh BOLT11 invoice for every unauthenticated request. The invoice amount is the deposit amount as described in Section 9.¶
The Authorization header carries a single base64url-encoded
JSON token (no auth-params) per [I-D.httpauth-payment].
The decoded object contains three top-level fields:¶
REQUIRED. An echo of the challenge auth-params from the most
recent WWW-Authenticate: Payment header: id,
realm, method, intent,
request, and (if present) expires. This
binds the credential to a specific, unexpired, single-use
challenge issued by the server. For the open and
topUp actions, where a new deposit invoice was issued,
this also binds the preimage to the specific invoice.¶
OPTIONAL. A payer identifier string, as defined by [I-D.httpauth-payment]. The RECOMMENDED format is a Decentralized Identifier (DID) per [W3C-DID]. Servers MUST NOT require this field.¶
REQUIRED. A JSON object containing the session action. The
action field discriminates the type; action-specific
fields are placed alongside it. Implementations MUST ignore
unknown fields to allow forward-compatible extensions.¶
The open action proves the deposit invoice was paid and registers the client's return invoice for future refunds.¶
REQUIRED. The string "open".¶
REQUIRED. Hex-encoded payment preimage. SHA-256(preimage) MUST equal challenge.request.paymentHash.¶
REQUIRED. BOLT11 invoice with no encoded amount. The server pays the unspent session balance to this invoice on close. MUST have no amount in the BOLT11 human-readable part. SHOULD have an expiry of at least 30 days to remain valid when the session eventually closes.¶
Example credential (decoded):¶
{
"challenge": {
"id": "nX7kPqWvT2mJrHsY4aDfEb",
"realm": "api.example.com",
"method": "lightning",
"intent": "session",
"request": "eyJ...",
"expires": "2026-03-15T12:05:00Z"
},
"payload": {
"action": "open",
"preimage": "a3f1e2d4b5c6a7e8...",
"returnInvoice": "lnbcrt1p5abc..."
}
}
¶
The bearer action authenticates a request against an existing session without any Lightning payment. The client proves session ownership by presenting the preimage that was revealed when the deposit invoice was paid.¶
REQUIRED. The string "bearer".¶
REQUIRED. The paymentHash of the original deposit invoice, identifying the session.¶
REQUIRED. Same preimage as the open action. SHA-256(preimage) MUST equal session.paymentHash.¶
The server verifies SHA-256(preimage) == session.paymentHash and that the session is open. Balance checking and deduction are handled by the streaming layer (see Section 13); bearer verification itself does not modify session.spent.¶
Security note: The payment preimage is a 32-byte random secret revealed only to the payer upon Lightning payment settlement. Using it directly as a bearer token allows the server to verify ownership with a single SHA-256 check against the stored paymentHash, without ever storing the secret. An alternative design using per-request HMAC tokens would require the server to store the preimage, which is a worse security posture. Implementations MUST use TLS (TLS 1.2 or higher; TLS 1.3 RECOMMENDED); the preimage carries the same threat model as any API bearer token.¶
Example credential (decoded):¶
{
"challenge": {
"id": "pR4mNvKqU8wLsYtZ1bCdFg",
"realm": "api.example.com",
"method": "lightning",
"intent": "session",
"request": "eyJ..."
},
"payload": {
"action": "bearer",
"sessionId": "7f3a1b2c4d5e6f...",
"preimage": "a3f1e2d4b5c6a7e8..."
}
}
¶
The topUp action proves payment of a new deposit invoice and adds the deposited amount to the existing session balance.¶
REQUIRED. The string "topUp".¶
REQUIRED. The paymentHash of the original deposit invoice, identifying the session.¶
REQUIRED. Preimage of the top-up invoice. SHA-256(topUpPreimage) MUST equal challenge.request.paymentHash of the fresh invoice issued for this top-up.¶
To obtain a challenge for a top-up, the client submits an
unauthenticated request (no Authorization header) to a protected
endpoint. The request MAY target the same resource URI that required
the top-up (e.g., the paused stream) or a different protected resource
URI in the same realm; the server issues a fresh deposit invoice in
either case. The server assigns a new challenge id and returns 402. The client
pays the invoice, obtains the preimage, and submits a topUp
credential echoing that challenge. The server MUST verify the
echoed challenge.id exists, has not expired, and has
not been previously consumed before crediting the balance.
This ensures each top-up invoice can only be used once.¶
Upon successful verification, the server MUST atomically add the
top-up amount to session.depositSats and return HTTP 200
with body {"status":"ok"}. The top-up credits the shared
session balance; any streams that were paused waiting for
sufficient balance SHOULD observe the updated balance and resume
delivery autonomously. The server MUST NOT initiate a new stream
in response to a topUp credential.¶
Example credential (decoded):¶
{
"challenge": {
"id": "qS5nOwLrV9xMtZuA2cDeGh",
"realm": "api.example.com",
"method": "lightning",
"intent": "session",
"request": "eyJ...",
"expires": "2026-03-15T12:10:00Z"
},
"payload": {
"action": "topUp",
"sessionId": "7f3a1b2c4d5e6f...",
"topUpPreimage": "b9c3a4e1d2f5..."
}
}
¶
The close action terminates the session and triggers a refund of the unspent balance to the client's return invoice.¶
REQUIRED. The string "close".¶
REQUIRED. The paymentHash of the original deposit invoice, identifying the session.¶
REQUIRED. Same preimage as the open action. Proves session ownership.¶
After verifying the preimage and marking the session closed, the server MUST:¶
Compute refundSats = session.depositSats - session.spent¶
If refundSats > 0, attempt to pay session.returnInvoice with amountSatsToSend = refundSats. If refundSats is zero, the server MUST NOT attempt to pay the return invoice.¶
Return HTTP 200 with a Payment-Receipt header (per Section 15) and body {"status":"closed","refundSats":N,"refundStatus":"succeeded"|"failed"|"skipped"}. If refundSats is zero, set refundStatus to "skipped". If the refund payment succeeded, set refundStatus to "succeeded". If the refund payment failed (e.g., the return invoice has expired or cannot be routed), set refundStatus to "failed"; the server MUST still close the session and MUST NOT reopen it. The server SHOULD log failed refunds for auditability. The server MUST NOT retry the refund indefinitely; a single best-effort attempt is sufficient.¶
Clients MUST NOT submit further actions on a closed session. Servers MUST reject any action on a closed session.¶
Example credential (decoded):¶
{
"challenge": {
"id": "rT6oQxMsW0yNuAvB3dEfHi",
"realm": "api.example.com",
"method": "lightning",
"intent": "session",
"request": "eyJ..."
},
"payload": {
"action": "close",
"sessionId": "7f3a1b2c4d5e6f...",
"preimage": "a3f1e2d4b5c6a7e8..."
}
}
¶
Example response (decoded receipt in Payment-Receipt header, body). Success:¶
{ "status": "closed", "refundSats": 140, "refundStatus": "succeeded" }
¶
Refund payment failed (session still closed):¶
{ "status": "closed", "refundSats": 140, "refundStatus": "failed" }
¶
No refund owed:¶
{ "status": "closed", "refundSats": 0, "refundStatus": "skipped" }
¶
The Payment-Receipt header MUST be present. For close, the receipt MUST also include refundSats and refundStatus (see Section 15). Example decoded receipt for close (success):¶
{"method":"lightning","reference":"7f3a1b2c4d5e6f...","status":"success","timestamp":"2026-03-10T21:00:00Z","refundSats":140,"refundStatus":"succeeded"}
¶
Close with refund failed:¶
{"method":"lightning","reference":"7f3a1b2c4d5e6f...","status":"success","timestamp":"2026-03-10T21:00:00Z","refundSats":140,"refundStatus":"failed"}
¶
The server determines the deposit amount when generating the challenge invoice. The deposit MUST be at least 1 unit of service (i.e., at least amount satoshis). The RECOMMENDED formula is:¶
depositSats = configured_depositAmount ?? (amount * 20)¶
The default multiplier of 20 is a recommendation that gives clients ~20 units of service before a top-up is required, balancing upfront cost against top-up frequency. Servers MAY use a different multiplier and MUST document their deposit policy.¶
Clients SHOULD inspect the depositAmount field in the challenge request before paying to confirm the expected deposit size. The deposit amount is encoded in the BOLT11 invoice and cannot be changed by the client.¶
Look up the stored challenge using credential.challenge.id. If not found or already consumed, reject.¶
Verify the echoed credential.challenge exactly matches the stored challenge params.¶
Decode preimage from hex¶
Compute SHA-256(preimage) and verify it equals the paymentHash stored with the challenge¶
Decode deposit amount from the BOLT11 invoice human-readable part¶
Verify depositSats >= amount (at least one unit of service)¶
Verify the returnInvoice is a valid BOLT11 invoice on the same network as the deposit invoice. Decode the returnInvoice and verify that the encoded amount resolves to 0 satoshis. The invoice MAY omit the amount field entirely, or MAY encode an explicit 0-satoshi amount; both are accepted. Invoices encoding a non-zero amount MUST be rejected, as the refund amount is determined by the server at close time.¶
Store session state: {paymentHash, depositSats, spent: 0, returnInvoice, status: "open"}¶
Return a receipt with reference = paymentHash (the session ID)¶
Look up the session by payload.sessionId¶
Verify session.status == "open"¶
Compute SHA-256(payload.preimage) and verify it equals session.paymentHash¶
Return a receipt¶
Bearer verification does not deduct from the session balance. All billing is handled by the streaming layer (see Section 13).¶
Look up the stored challenge using credential.challenge.id. If not found, expired, or already consumed, reject.¶
Verify the echoed credential.challenge exactly matches the stored challenge params. Mark the challenge consumed.¶
Look up the session by payload.sessionId¶
Verify session.status == "open"¶
Compute SHA-256(payload.topUpPreimage) and verify it equals the paymentHash stored with the challenge¶
Decode top-up amount from the BOLT11 invoice in the stored challenge's depositInvoice¶
Atomically update session.depositSats += topUpSats¶
Return a receipt¶
Look up the session by payload.sessionId¶
Verify session.status == "open"¶
Compute SHA-256(payload.preimage) and verify it equals session.paymentHash¶
Mark session.status = "closed"¶
If refundSats > 0, attempt to pay session.returnInvoice with amountSatsToSend = refundSats. If refundSats is zero, the server MUST NOT attempt to pay the return invoice.¶
Return HTTP 200 with a Payment-Receipt header and body {"status":"closed","refundSats":N,"refundStatus":"succeeded"|"failed"|"skipped"}. The server SHOULD log failed refunds. The server MUST NOT retry the refund indefinitely.¶
Retrying a credential after a network failure MUST NOT result in
double-crediting or double-refunding. Servers MUST enforce
idempotency keyed by credential.challenge.id:¶
A challenge that has already been successfully consumed MUST return the original HTTP response (same status code and body) when presented again, without re-executing the action.¶
Servers MUST store the result of each consumed challenge (status
code, response body) for at least the duration of the challenge's
expires window, or for a minimum of 5 minutes if no
expiry was set.¶
Servers MUST atomically mark a challenge consumed and record its result in a single operation, so that a crash between verification and response does not leave the challenge in an ambiguous state on retry.¶
A server MAY close an open session without a client close action. Common triggers include idle timeout (no bearer or topUp action received within the server's configured inactivity window) and planned maintenance. When a server initiates a close, it MUST:¶
Mark session.status = "closed" atomically before attempting the refund, to prevent concurrent bearer actions from spending against a session that is being closed.¶
Compute refundSats = session.depositSats - session.spent¶
If refundSats > 0, attempt to pay session.returnInvoice with amountSatsToSend = refundSats, subject to the same fee constraints as a client-initiated close. If refundSats is zero, the server MUST NOT attempt to pay the return invoice.¶
If the refund payment fails (e.g., the return invoice has expired or cannot be routed), the server MUST NOT reopen the session. The server SHOULD log the failed refund attempt including the sessionId and refundSats for auditability. The server MUST NOT retry indefinitely; a single best-effort attempt is sufficient.¶
After a server-initiated close, subsequent bearer or close actions referencing that session will be rejected per the normal closed-session handling (see Section 17.7). Clients discover the server-initiated close when their next bearer action is rejected and SHOULD treat the rejection as a signal to open a new session.¶
To reduce the risk of return invoice expiry, clients SHOULD supply a return invoice with an expiry that comfortably exceeds the server's advertised idleTimeout. The RECOMMENDED return invoice expiry is at least twice the idleTimeout value.¶
For streaming responses, the challenge request's amount field is the
cost per emitted chunk in satoshis. Bearer verification does not deduct
from the session balance; instead, the streaming layer deducts amount
sats from the balance for each chunk delivered, reading the per-unit
cost directly from the echoed challenge request.¶
When a streaming response exhausts the available session balance mid-stream, the server MUST:¶
Stop delivering additional metered content immediately¶
Emit a payment-need-topup SSE event on the existing connection¶
Hold the connection open and pause delivery¶
Poll or await an increase in session.depositSats. Once
a topUp credential is successfully verified and the session
balance is sufficient to cover the next unit of service, resume
delivery on the paused connection.¶
A top-up credits the shared session balance; the server does not need to be explicitly notified which connection to resume. Any paused streams that observe sufficient balance after a top-up SHOULD resume autonomously. Servers SHOULD close any held connection if the balance does not become sufficient within a reasonable timeout (RECOMMENDED: 60 seconds). When the timeout fires, the server MUST close the SSE connection. The server SHOULD emit a final SSE event (e.g., event: session-timeout) with session balance details before closing (see Section 13.2), so the client does not only observe a dropped connection.¶
Holding the connection open preserves any upstream state the server maintains (e.g., an in-flight request to an upstream LLM provider). Clients benefit because they do not need to replay the original request or deduplicate partially-delivered content.¶
For SSE [SSE] responses, the payment-need-topup event MUST be formatted as follows:¶
event: payment-need-topup
data: {"sessionId":"7f3a...","balanceSpent":300,"balanceRequired":2}
¶
The event data MUST be a JSON object containing:¶
When the server closes a held SSE connection because the balance did not become sufficient within the configured timeout, the server SHOULD emit a session-timeout event immediately before closing the connection. This allows the client to distinguish a timeout from a generic network drop and to show session balance details to the user.¶
event: session-timeout
data: {"sessionId":"7f3a...","balanceSpent":300,"balanceRequired":2}
¶
The event data MUST be a JSON object containing the same fields as the payment-need-topup event: sessionId, balanceSpent, and balanceRequired (see Section 13.1). After emitting this event, the server MUST close the SSE connection.¶
Upon receiving a payment-need-topup event, the client MUST:¶
Continue holding the original SSE response reader open (do not close the connection)¶
Submit an unauthenticated request to the same resource URI to obtain a new 402 challenge with a fresh deposit invoice¶
Pay the deposit invoice and obtain the preimage¶
Retry the request with a topUp credential; the server verifies the preimage, credits the session balance, and returns HTTP 200¶
Continue reading from the original SSE reader; the paused stream observes the updated balance and resumes automatically¶
The topUp request returns HTTP 200 with body {"status":"ok"}
to acknowledge the balance credit. The actual stream content
resumes on the original connection, not the topUp response.¶
Client Server Lightning Network
| | |
| (1) [SSE stream open, | |
| receiving chunks] | |
|<-------------------------- | |
| data: {chunk} | |
| event: need-topup | (server pauses, holds open)|
| | |
| (2) GET /generate | |
| (unauthenticated) | |
|--------------------------> | |
| | |
| (3) 402 + deposit invoice | |
|<-------------------------- | |
| | |
| (4) Pay deposit invoice | |
|------------------------------------------------------> |
| (5) Preimage | |
|<------------------------------------------------------ |
| | |
| (6) GET /generate | |
| action="topUp" | |
|--------------------------> | |
| | |
| (7) 200 {"status":"ok"} | |
|<-------------------------- | |
| data: {chunk} (resumed) | (original SSE connection) |
| data: [DONE] | |
| | |
¶
Servers MUST persist session state in a durable store keyed by sessionId. The minimum required state is:¶
SHA-256 hash of the deposit preimage. Serves as the session identifier.¶
Total satoshis deposited. Increases with each successful top-up.¶
Running total of satoshis charged against the session.¶
BOLT11 return invoice for refunds on close.¶
Either "open" or "closed".¶
Servers MUST serialize all balance updates to prevent race conditions. Concurrent bearer and topUp requests for the same session MUST NOT result in double-spending the same satoshis.¶
Upon successful verification of any action (including close), the server MUST attach a payment receipt to the response via the Payment-Receipt header per [I-D.httpauth-payment] {#receipt}. The header receipt contains:¶
REQUIRED. The string "lightning".¶
REQUIRED. The session ID (paymentHash).¶
REQUIRED. The string "success".¶
For close actions only. REQUIRED in the receipt when the action is close. The unspent balance that was (or was attempted to be) refunded, as a number. Omitted for non-close actions.¶
For close actions only. REQUIRED in the receipt when the action is close. One of "succeeded" (refund payment completed), "failed" (refund payment did not complete, e.g., return invoice expired or could not be routed), or "skipped" (refundSats was zero, no payment attempted). Omitted for non-close actions.¶
For streaming responses, the Payment-Receipt header is attached at response initiation before any chunks are delivered. Because the final consumption totals are not yet known at that point, the server MUST also emit a final payment-receipt SSE [SSE] event once the stream completes, with the following JSON data:¶
REQUIRED. The string "lightning".¶
REQUIRED. The session ID (paymentHash).¶
REQUIRED. The string "success".¶
REQUIRED. Total satoshis deducted from the session balance during this stream, as a number.¶
REQUIRED. Number of chunks delivered in this stream.¶
The payment-receipt event MUST be emitted before the [DONE] sentinel. Example:¶
event: payment-receipt
data: {"method":"lightning","reference":"7f3a...","status":"success",
"timestamp":"2026-03-11T00:00:00Z","spent":202,"units":101}
data: [DONE]
¶
When rejecting a credential, the server MUST return HTTP 402 (Payment
Required) with a fresh WWW-Authenticate: Payment challenge per
[I-D.httpauth-payment]. The server SHOULD include a response body
conforming to RFC 9457 [RFC9457] Problem Details, with
Content-Type: application/problem+json. The following
problem types are defined for this intent:¶
HTTP 402. The credential token could not be decoded or parsed,
or required fields are absent or have the wrong type. A fresh
challenge MUST be included in WWW-Authenticate.¶
HTTP 402. The credential.challenge.id does not match
any challenge issued by this server, or has already been consumed.
A fresh challenge MUST be included in WWW-Authenticate.¶
HTTP 402. SHA-256(payload.preimage) or SHA-256(payload.topUpPreimage)
does not equal the paymentHash stored for the identified challenge.
A fresh challenge MUST be included in WWW-Authenticate.¶
HTTP 402. The payload.sessionId does not match any
session stored by this server. A fresh challenge MUST be included
in WWW-Authenticate.¶
HTTP 402. The session identified by payload.sessionId
has status "closed". No further actions are accepted. A fresh
challenge MUST be included in WWW-Authenticate.¶
HTTP 402. The session balance is insufficient to cover the
requested operation. The client SHOULD top up the session. A fresh
challenge MUST be included in WWW-Authenticate.¶
HTTP 402. The challenge identified by
credential.challenge.id has passed its expiry time.
The client MUST obtain a fresh challenge. A fresh challenge MUST be
included in WWW-Authenticate.¶
HTTP 402. The payload.returnInvoice in an open action
is not a valid BOLT11 invoice, or encodes a non-zero amount. A fresh
challenge MUST be included in WWW-Authenticate.¶
The preimage is transmitted in every bearer request. Implementations MUST use TLS (HTTPS) for all endpoints protected by this method (TLS 1.2 or higher; TLS 1.3 RECOMMENDED). The preimage has the same exposure risk as any API bearer token; its security properties are equivalent to a 256-bit random secret transmitted over an encrypted channel.¶
Servers MUST verify SHA-256(preimage) == paymentHash for all open, bearer, and close actions. Failure to verify allows any party to impersonate a session they did not fund.¶
The paymentHash of the deposit invoice serves as the session identifier. Payment hashes are globally unique within the Lightning Network for a given invoice. Servers MUST NOT allow session IDs to be guessed or reused across sessions.¶
Balance deduction is the exclusive responsibility of the streaming
layer (see Section 13); bearer credential
verification itself does not modify session.spent.
The streaming layer MUST atomically deduct amount sats
from the available balance for each chunk delivered, to prevent
concurrent chunk deliveries from overdrawing the session balance.
TopUp actions MUST atomically increment session.depositSats
so that any paused streams observe the updated balance
immediately after the credit.¶
Servers MUST verify that returnInvoice is a valid BOLT11 invoice with no encoded amount before storing it. Servers SHOULD reject return invoices that encode an amount, as the refund amount is determined by the server at close time and must not be constrained by the invoice.¶
The topUpPreimage MUST be verified against challenge.request.paymentHash — the payment hash of the fresh invoice the server issued for this specific top-up request. This prevents a client from replaying an old top-up preimage to fraudulently inflate their session balance.¶
Closed sessions MUST be retained in the store (with status: "closed") to prevent replay attacks using the preimage of a closed session. Servers MUST reject any action submitted against a closed session.¶
The "lightning" payment method is registered in the "HTTP Payment Methods" registry by [I-D.lightning-charge]; this document does not register it again.¶
This document requests registration of the following entry in the "HTTP Payment Intents" registry established by [I-D.httpauth-payment]:¶
| Intent | Applicable Methods | Description | Reference | Contact |
|---|---|---|---|---|
session
|
lightning
|
Prepaid session with per-unit streaming billing and refund on close | This document | Lightspark (contact@lightspark.com) |
This document defines the following problem type URIs under
the https://paymentauth.org/problems/lightning/ namespace,
for use with RFC 9457 [RFC9457] Problem Details:¶
| Type URI | HTTP Status | Description | Reference |
|---|---|---|---|
lightning/malformed-credential
|
402 | Credential is unparseable or missing required fields | This document |
lightning/unknown-challenge
|
402 | Challenge ID not found or already consumed | This document |
lightning/invalid-preimage
|
402 | Preimage does not match stored payment hash | This document |
lightning/session-not-found
|
402 | Session ID not found | This document |
lightning/session-closed
|
402 | Session is closed; no further actions accepted | This document |
lightning/insufficient-balance
|
402 | Session balance insufficient for requested operation | This document |
lightning/challenge-expired
|
402 | Challenge has passed its expiry time | This document |
lightning/invalid-return-invoice
|
402 | Return invoice invalid or encodes non-zero amount | This document |
GET /generate HTTP/1.1 Host: api.example.com HTTP/1.1 402 Payment Required WWW-Authenticate: Payment id="nX7kPqWvT2mJrHsY4aDfEb", realm="api.example.com", method="lightning", intent="session", request="eyJhbW91bnQiOiIyIiwiY3VycmVuY3kiOiJCVEMiLCJkZXBvc2l0SW52b2ljZSI6ImxuYmNydDFwNW16ZnNhLi4uIiwicGF5bWVudEhhc2giOiI3ZjNhLi4uIiwiZGVwb3NpdEFtb3VudCI6IjMwMCJ9", expires="2026-03-15T12:05:00Z" Cache-Control: no-store¶
Decoded request:¶
{
"amount": "2",
"currency": "sat",
"description": "LLM token stream",
"depositInvoice": "lnbcrt1p5mzfsa...",
"paymentHash": "7f3a1b2c4d5e6f...",
"depositAmount": "300"
}
¶
{
"challenge": {
"id": "nX7kPqWvT2mJrHsY4aDfEb",
"realm": "api.example.com",
"method": "lightning",
"intent": "session",
"request": "eyJ...",
"expires": "2026-03-15T12:05:00Z"
},
"payload": {
"action": "open",
"preimage": "a3f1e2d4b5c6a7e8...",
"returnInvoice": "lnbcrt1p5abc..."
}
}
¶
{
"challenge": {
"id": "pR4mNvKqU8wLsYtZ1bCdFg",
"realm": "api.example.com",
"method": "lightning",
"intent": "session",
"request": "eyJ..."
},
"payload": {
"action": "bearer",
"sessionId": "7f3a1b2c4d5e6f...",
"preimage": "a3f1e2d4b5c6a7e8..."
}
}
¶
event: payment-need-topup
data: {"sessionId":"7f3a...","balanceSpent":300,"balanceRequired":2}
¶
{
"challenge": {
"id": "qS5nOwLrV9xMtZuA2cDeGh",
"realm": "api.example.com",
"method": "lightning",
"intent": "session",
"request": "eyJ...",
"expires": "2026-03-15T12:10:00Z"
},
"payload": {
"action": "topUp",
"sessionId": "7f3a1b2c4d5e6f...",
"topUpPreimage": "b9c3a4e1d2f5..."
}
}
¶
Credential (decoded):¶
{
"challenge": {
"id": "rT6oQxMsW0yNuAvB3dEfHi",
"realm": "api.example.com",
"method": "lightning",
"intent": "session",
"request": "eyJ..."
},
"payload": {
"action": "close",
"sessionId": "7f3a1b2c4d5e6f...",
"preimage": "a3f1e2d4b5c6a7e8..."
}
}
¶
Response body:¶
{ "status": "closed", "refundSats": 140 }
¶
The schemas in this appendix use JSON Schema vocabulary for illustrative purposes only. They are informational; the normative definitions are in the body of this document. No specific JSON Schema draft version is required or assumed.¶
{
"type": "object",
"required": [
"amount", "currency", "paymentHash"
],
"properties": {
"amount": { "type": "string" },
"currency": { "type": "string", "const": "sat" },
"description": { "type": "string" },
"unitType": { "type": "string" },
"depositInvoice": { "type": "string" },
"paymentHash": { "type": "string" },
"depositAmount": { "type": "string" },
"idleTimeout": { "type": "string" }
}
}
¶
{
"oneOf": [
{
"type": "object",
"required": ["action", "preimage", "returnInvoice"],
"properties": {
"action": { "const": "open" },
"preimage": { "type": "string" },
"returnInvoice": { "type": "string" }
}
},
{
"type": "object",
"required": ["action", "sessionId", "preimage"],
"properties": {
"action": { "const": "bearer" },
"sessionId": { "type": "string" },
"preimage": { "type": "string" }
}
},
{
"type": "object",
"required": ["action", "sessionId", "topUpPreimage"],
"properties": {
"action": { "const": "topUp" },
"sessionId": { "type": "string" },
"topUpPreimage": { "type": "string" }
}
},
{
"type": "object",
"required": ["action", "sessionId", "preimage"],
"properties": {
"action": { "const": "close" },
"sessionId": { "type": "string" },
"preimage": { "type": "string" }
}
}
]
}
¶
{
"type": "object",
"required": ["method", "reference", "status", "timestamp"],
"properties": {
"method": { "type": "string", "const": "lightning" },
"reference": { "type": "string" },
"status": { "type": "string", "const": "success" },
"timestamp": { "type": "string", "format": "date-time" }
}
}
¶
The authors thank the Spark SDK team, the Tempo Labs team for their prior work on the session intent for EVM-based payment channels, and the broader Lightning Network developer community. The authors also thank Brendan Ryan for his review of this document.¶