Language-agnostic contract for the Lightning mediator — the service that bridges Archon DIDs to the Lightning Network via LNbits and Core Lightning (CLN). It backs the wallet-side Lightning features (balance/invoice/pay/zap) and the L402 paywall invoices that Drawbridge issues on inbound API calls.
The canonical implementation is services/mediators/lightning/.
Related specs. The Lightning mediator is the HTTP backend that the Keymaster’s
/api/v1/lightning/*routes forward to, and that Drawbridge uses for L402 invoice creation. It talks to LNbits for user-wallet operations and to CLN’s REST plugin for L402. It resolves recipient DIDs through the Gatekeeper.
Thin HTTP wrapper over two external systems:
clnrest plugin) — the node’s own Lightning wallet,
used for L402 paywall invoices that monetize Drawbridge API access.Plus three pieces of state in Redis:
DID → invoiceKey so third
parties can pay a DID at /invoice/:did without knowing the LNbits
invoiceKey directly.paymentHash.The service carries no key material and is not authoritative over any balance — all actual Lightning state lives in LNbits and CLN.
The service binds to ${ARCHON_BIND_ADDRESS}:${ARCHON_LIGHTNING_MEDIATOR_PORT}
(default 0.0.0.0:4235). Routes live under /api/v1 with three
unversioned routes (/ready, /version, /metrics) plus a single
public endpoint (/invoice/:did).
| Method | Path | Admin? | Notes |
|---|---|---|---|
GET |
/ready |
no | { ready, dependencies: { redis, clnConfigured, lnbitsConfigured } }. HTTP 503 if redis is unreachable. |
GET |
/version |
no | { version, commit } (commit 7 chars). |
GET |
/metrics |
no | Prometheus. |
GET |
/api/v1/lightning/supported |
no | { supported: true, mediator: "lightning-mediator", clnConfigured, lnbitsConfigured }. Used by clients to probe capabilities before committing to Lightning operations. Note the asymmetry: clnConfigured requires BOTH clnRune and clnRestUrl to be set, while lnbitsConfigured requires only the LNbits URL. |
POST |
/api/v1/lightning/wallet |
yes | { name? } → new LNbits wallet. Returns LnbitsWallet = { walletId, adminKey, invoiceKey }. |
POST |
/api/v1/lightning/balance |
yes | { invoiceKey } → { balance: <sats> }. |
POST |
/api/v1/lightning/invoice |
yes | { invoiceKey, amount, memo } → { paymentRequest, paymentHash } (LNbits path; the full LightningInvoice shape is only returned by /l402/invoice via CLN). |
POST |
/api/v1/lightning/pay |
yes | { adminKey, bolt11 } → { paymentHash }. |
POST |
/api/v1/lightning/payment |
yes | { invoiceKey, paymentHash } → LightningPaymentStatus & { paymentHash }. |
POST |
/api/v1/lightning/payments |
yes | { adminKey } → { payments: LnbitsPayment[] }. |
POST |
/api/v1/lightning/publish |
yes | { did, invoiceKey } — stores the mapping via store.savePublishedLightning(did, invoiceKey). Returns { ok: true, publicHost }. Does NOT modify the DID document (the DID-document service entry is added client-side by Keymaster). Returns HTTP 503 if publicHost is not yet available. |
DELETE |
/api/v1/lightning/publish/:did |
yes | Removes the mapping. |
POST |
/api/v1/lightning/zap |
yes | { adminKey, did, amount, memo? }. Resolves recipient (DID or LUD-16 address), requests an invoice, and pays it via LNbits. See §4. |
POST |
/api/v1/l402/invoice |
yes | { amountSat, memo? } — creates a CLN invoice for L402. Returns the full LightningInvoice shape { paymentRequest, paymentHash, amountSat, expiry, label }. |
POST |
/api/v1/l402/check |
yes | { paymentHash } → CLN listinvoices response (paid / pending / expired). |
POST |
/api/v1/l402/pending |
yes | Body: PendingInvoiceData. Persists the record for later redemption. Returns HTTP 201 { ok: true, paymentHash }. |
GET |
/api/v1/l402/pending/:paymentHash |
yes | Returns the stored PendingInvoiceData or HTTP 404. |
DELETE |
/api/v1/l402/pending/:paymentHash |
yes | Removes the record. Returns { ok: true, paymentHash }. |
GET |
/invoice/:did |
no (public) | Query: amount (required sats), memo (optional). Looks up the DID’s invoiceKey via /api/v1/lightning/publish storage, asks LNbits to create an invoice, returns { paymentRequest, paymentHash, ... }. Used by external zappers and the Archon HTTP zap flow. |
All routes under /api/v1/* (except /lightning/supported) require the
admin API key:
X-Archon-Admin-Key{ "error": "Invalid admin API key" }{ "error": "Admin API key required" }ARCHON_ADMIN_API_KEY MUST be set for production — when empty,
all admin calls 403 with { "error": "Admin API key not configured" }
(different from Gatekeeper/Keymaster behavior, where empty = open).CORS: none by default. The Lightning mediator is typically reached only from the Keymaster / Drawbridge on the same private network.
Body limit: 1mb global (enforced via Express json({ limit: '1mb' })).
Constant-time admin-key comparison is used (crypto.timingSafeEqual).
Every 4xx / 5xx response: application/json { "error": "<message>" }.
502 is used for upstream-LNbits/CLN errors; 400 for
LightningPaymentError (validation-class failures from LNbits). The
/l402/* routes do not pre-check CLN configuration at startup; a
missing rune surfaces at call time as a LightningUnavailableError
which the handler returns as HTTP 502 (not 503).
/ready checks only Redis reachability (ping/pong). LNbits and CLN
are tested for “configured” (non-empty URL / rune) but not probed —
they’re allowed to be up-and-down independently without cycling the
mediator’s readiness.
HTTP status: 200 when Redis is up, 503 when not.
ARCHON_LIGHTNING_MEDIATOR_LNBITS_URL
(default http://lnbits:5000, matching the bundled compose service
name)./api/v1/lightning/* and for
/invoice/:did. Each admin-gated LNbits route returns HTTP 503
{ "error": "Lightning (LNBits) not configured" } when the URL is
empty./lightning/wallet).ARCHON_LIGHTNING_MEDIATOR_CLN_REST_URL
(default https://cln:3001).ARCHON_LIGHTNING_MEDIATOR_CLN_RUNE (issued by the CLN node
operator; grants the invoice and listinvoices permissions)./api/v1/l402/* routes. There is no startup
pre-check: /l402/invoice and /l402/check always call CLN, and a
missing rune throws LightningUnavailableError which the handler
returns as HTTP 502 (never 503).ARCHON_LIGHTNING_MEDIATOR_REDIS_URL or falls back to
ARCHON_REDIS_URL or redis://localhost:6379.lightning-mediator:* (see §5).POST /api/v1/lightning/zap is the sole non-trivial handler. It
accepts a recipient in one of two forms:
name@domain)Detected by did.includes('@') && !did.startsWith('did:'). Flow:
<name>@<domain>, construct
https://<domain>/.well-known/lnurlp/<urlencode(name)>.domain resolves to a private address
(localhost|127.*|10.*|172.(16-31).*|192.168.*).status === "ERROR" or
missing callback.https: and not a private address.amountMsats = amount * 1000 against minSendable /
maxSendable from the LNURL response.?amount=<msats>&comment=<memo> to the callback if it has no
existing query string, otherwise append &amount=<msats>&comment=<memo>
(comment only if memo is non-empty) and fetch it.pr field is the BOLT11 invoice.did:cid:...)service.type === "Lightning".{ "error": "Recipient DID has no Lightning service
endpoint" }..onion hosts MUST use http:..onion MUST use https: and MUST NOT be a private host.<serviceEndpoint>?amount=<sats>&memo=<memo>.publicHost (see §6.3)
and the endpoint’s hostname matches, shortcut through the internal
Drawbridge port (http://drawbridge:<drawbridgePort>) to avoid
looping back through our own onion.ARCHON_LIGHTNING_MEDIATOR_TOR_PROXY when the destination is
.onion).paymentRequest from the response.Either path produces a BOLT11 string; the mediator hands it to
lnbits.payInvoice(lnbitsUrl, adminKey, paymentRequest) and returns
the LNbits response ({ paymentHash }).
Namespace: lightning-mediator:. Value encoding varies by key (see
Type column).
| Key | Type | TTL | Contents |
|---|---|---|---|
lightning-mediator:published:<DID> |
STRING | none | invoiceKey for the DID |
lightning-mediator:pending:<paymentHash> |
HASH | ~ expiresAt - now (seconds) |
PendingInvoiceData (stored via hmset) |
lightning-mediator:payment:<id> |
HASH | none | LightningPaymentRecord (stored via hmset) |
lightning-mediator:payments:did:<DID> |
SORTED SET | none | Payment IDs for that DID (written via zadd, read via zrange; used by getPaymentsByDid) |
PendingInvoiceData:
{
"paymentHash": "<hex>",
"macaroonId": "<opaque id>",
"serializedMacaroon": "<base64>",
"did": "<DID>",
"scope": ["<cap>", ...],
"amountSat": <int>,
"expiresAt": <unix seconds>,
"createdAt": <unix seconds>
}
LightningPaymentRecord:
{
"id": "<uuid>",
"did": "<DID>",
"method": "lightning",
"paymentHash": "<hex>",
"amountSat": <int>,
"createdAt": <unix seconds>,
"macaroonId": "<opaque id>",
"scope": ["<cap>", ...]
}
LnbitsPayment (returned by /api/v1/lightning/payments):
{
"paymentHash": "<hex>",
"amount": <int>,
"fee": <int>,
"memo": "<string>",
"time": <unix seconds>,
"pending": <bool>,
"status": "<string>",
"expiry": <unix seconds> // optional
}
A new implementation MUST use this namespace and key structure if the Redis instance is shared with the reference TypeScript service.
| Variable | Default | Meaning |
|---|---|---|
ARCHON_LIGHTNING_MEDIATOR_PORT |
4235 |
HTTP listen port. |
ARCHON_BIND_ADDRESS |
0.0.0.0 |
|
ARCHON_ADMIN_API_KEY |
empty | Required; empty → all admin routes 403. |
ARCHON_LIGHTNING_MEDIATOR_REDIS_URL |
${ARCHON_REDIS_URL:-redis://localhost:6379} |
|
ARCHON_GATEKEEPER_URL |
http://localhost:4224 |
Used to resolve recipient DIDs in zaps. |
ARCHON_LIGHTNING_MEDIATOR_CLN_REST_URL |
https://cln:3001 |
|
ARCHON_LIGHTNING_MEDIATOR_CLN_RUNE |
empty | CLN authorization rune. |
ARCHON_LIGHTNING_MEDIATOR_LNBITS_URL |
http://lnbits:5000 |
|
ARCHON_LIGHTNING_MEDIATOR_PUBLIC_HOST |
empty | External URL the mediator advertises (overrides onion discovery). |
ARCHON_DRAWBRIDGE_PUBLIC_HOST |
empty | Preferred over PUBLIC_HOST if set. |
ARCHON_DRAWBRIDGE_PORT |
4222 |
Used for internal shortcut in zap. |
ARCHON_LIGHTNING_MEDIATOR_TOR_PROXY |
empty | SOCKS5 proxy for .onion lookups. Expected form: host:port. If set but malformed (split yields empty host or port), the mediator falls back to localhost:9050. |
GIT_COMMIT |
unknown |
Build commit. |
On the first call that needs a public host, the mediator resolves it in this order and caches the result:
ARCHON_DRAWBRIDGE_PUBLIC_HOST env varARCHON_LIGHTNING_MEDIATOR_PUBLIC_HOST env var/data/tor/hostname (the Tor onion hostname volume)| Metric | Type | Labels |
|---|---|---|
lightning_mediator_http_requests_total |
counter | method, route, status |
lightning_mediator_version_info |
gauge | version, commit |
Plus the standard Prometheus process metrics (process_*). The
route label collapses dynamic segments — but only DID-shaped
segments are normalised (normalizePath matches the
/lightning/publish/ regex against DID-shaped values); non-DID
dynamic segments (e.g. payment hashes) stay as-is in the label:
/lightning/publish/<DID> -> /lightning/publish/:did
/invoice/<DID> -> /invoice/:did
/l402/pending/<hash> -> /l402/pending/<hash> (NOT normalised)
Route labels do not include the /api/v1 prefix (they come from
req.path under the already-mounted v1 router).
pino at LOG_LEVEL (default info) + morgan('dev') for HTTP
requests. Errors logged as structured { err } objects.
No fixed log lines expected from downstream consumers.
ghcr.io/archetech/lightning-mediatorNo dedicated conformance tests; validated end-to-end through the zap flow in the CLI test suite and by Drawbridge integration tests.
A conformant third implementation MUST:
/invoice/:did public endpoint’s shape so external
zappers can pay published DIDs unchanged.