NWC Payment Listener
Transport-only relay bridge: live NWC connections, payment webhooks, and a fast path for outgoing NWC calls.
Overview
The NWC Payment Listener (apps/listener) is a long-running Node service that
solves what a serverless web app can't: it keeps Nostr relay websockets open.
It holds one live NWC connection per active RemoteWallet of type NWC,
forwards incoming NIP-47 notifications to the web app as HMAC-signed webhooks,
and lets the web app send NWC requests (card withdraws, invoice minting) over
the already-open sockets instead of paying a relay handshake per call.
It is strictly transport-only — payment matching, invoice state and receipts stay in the web app. It is also optional: without it, NWC calls fall back to per-request relay connections and no incoming notifications are received.
How it works
- Postgres LISTEN/NOTIFY — a database trigger on the
RemoteWallettable firesremote_wallet_changedon every insert/update/delete; the listener reconciles its connection pool per notification (with a periodic full reconcile as a safety net). No polling. - Live pool — one
@getalby/sdkclient per active NWC wallet, subscribed topayment_received/payment_sent, auto-reconnecting with backoff. A client becomesreadyonly after aget_infonegotiation proves the NWC request/response channel works. - Persistent dedup — processed events land in a service-owned
listenerschema in the shared Postgres (atomic claim, ~30-day retention), which also powers the dashboard's recent-events feed and webhook delivery tracking. - Idempotent outgoing payments — card payments are claimed in
listener.nwc_requestsbefore NWC dispatch. Repeated callbacks join the original operation, while notifications andlookup_invoicereconcile late/ambiguous outcomes without republishing. - RemoteWallet forwarding journal — personal receive-forwarding legs use the same durable request journal and deterministic request IDs. The webhook records the source receipt before acknowledgement; the immediate wake-up and ten-minute scheduler drive the owner-scoped forwarding reconciler.
- Missed-event recovery — a persisted per-wallet cursor anchors a hybrid
catch-up after downtime: the wallet's own
list_transactionsledger (primary, relay-independent) plus a best-effort relay replay, both feeding the same dedup pipeline and flaggedrecoveredin the dashboard. - Dead-wallet detection — a disposable LNCurl wallet destroyed by its
provider goes silent while its relays stay up. The listener probes liveness
(
get_info) and, afterDEAD_THRESHOLD_HOURS(default 4) of no reply with relays connected, reports awallet_deadwebhook; web archives it asDEAD(LNCurl-provider wallets only), which reconciles it out of the pool. - Resilient by design —
GET /statusnever 5xx's (each part degrades independently), andunhandledRejection/uncaughtException/ pg-pool error backstops keep the daemon alive through relay churn and DB blips. - Webhooks to the web app —
POST /api/webhooks/nwc, signed withHMAC-SHA256(secret, "<timestamp>.<body>"), retried with backoff and swept again later if the web app was down. Full contract below. - HTTP API — health/readiness/status, wallet-ID-routed idempotent card
payments, and the backward-compatible
/nwc/requestgeneral proxy. Full reference below.
HTTP API
The listener serves the following endpoints on port 4100. Authenticated
endpoints require Authorization: Bearer <LISTENER_REQUEST_AUTH_SECRET>
(compared constant-time); when the dedicated request secret is unset, it
falls back to LISTENER_AUTH_SECRET.
All schemas are defined in packages/shared/src/listener.ts — both services
import the same Zod definitions, so the shapes below cannot drift.
| Endpoint | Auth | Purpose |
|---|---|---|
GET /health | none | Liveness — used by container healthchecks |
GET /ready | none | Capability advertisement and ready/not-ready wallet counts |
GET /status | Bearer | Relays, connections, counters, recent events |
POST /v1/nwc/payments | Bearer | Start or join an idempotent card payment |
GET /v1/nwc/payments/:requestId | Bearer | Read/reconcile a durable payment outcome |
POST /nwc/request | Bearer | Proxy an NWC call over the pooled connection |
GET /health
No auth. Returns liveness plus an informational database flag (the endpoint
stays 200 even when the DB check fails, so orchestrators don't restart the
container for a transient Postgres blip):
{ "status": "ok", "db": true }GET /ready
No auth. The stable capability name lets web probe support outside a payment request; wallet counts distinguish service readiness from per-wallet NWC readiness:
{
"status": "ready",
"capabilities": ["nwc_payments_v1"],
"wallets": { "total": 12, "ready": 11, "notReady": 1 }
}GET /status
Bearer-authenticated. Powers the Admin → NWC Listener dashboard (web
proxies it through GET /api/admin/listener/status). Response
(listenerStatusResponseSchema):
{
startedAt: string // ISO timestamp of process start
uptimeSeconds: number
relays: {
url: string
connected: boolean
walletCount: number // NWC connections subscribed via this relay
}[]
connections: {
walletId: string
walletName?: string | null
userId?: string | null
state: 'connecting' | 'negotiating' | 'ready' | 'disconnected'
| 'error' | 'closed'
connected: boolean
relayUrls: string[]
lastEventAt: string | null // ISO — null until the first event
lastErrorAt: string | null
lastError: string | null
lastCatchupAt?: string | null // last completed missed-event catch-up run
}[]
counters: {
eventsReceived: number
eventsDuplicate: number
webhooksDelivered: number
webhooksFailed: number
webhooksPending?: number // currently-undelivered, still retrying (0 = caught up)
nwcRequests: number
nwcRequestErrors: number
nwcPayments?: number // unique idempotent NWC dispatches
nwcPaymentDuplicates?: number // joined in-memory/durable requests
nwcPaymentsPending?: number // foreground SDK operations still running
eventsRecovered?: number // synthesized by downtime catch-up
catchupRuns?: number
catchupErrors?: number
}
recentEvents: { // newest first, max 100
eventKey: string
walletId: string
walletName?: string | null // null if the wallet was deleted
type: string
paymentHash: string | null
amountMsats: number | null
receivedAt: string
webhookStatus: 'pending' | 'delivered' | 'failed'
recovered?: boolean
}[]
}POST /v1/nwc/payments
Card payments use a dedicated wallet-ID-routed contract; the credential-bearing NWC connection string never crosses this endpoint:
{
requestId: string // deterministic 64-hex idempotency key
walletId: string
invoice: string
paymentHash: string // 64-hex
idempotencyScope?: string // proxy source-payment ID
attemptNo?: number // proxy retry sequence within that scope
waitMs?: number // 100–8000, default 8000
}The listener atomically claims requestId in its service-owned
listener.nwc_requests table before dispatch. Concurrent duplicates share one
promise; durable duplicates return the stored outcome; a different payload
under the same ID returns 409 request_conflict. A missing wallet receives a
targeted database reconcile before the listener returns not_started.
The HTTP request long-polls for at most eight seconds. If it returns 202, the
SDK operation keeps running and journals its eventual result—the HTTP timeout
does not cancel NWC. The response is:
{ ok: true, status: 'succeeded', requestId, preimage, feesPaidMsats }
| {
ok: false
status: 'pending' | 'unknown' | 'rejected' | 'not_started'
requestId
error?: { code, message, walletErrorCode? }
}Only not_started proves the listener never invoked NWC and permits web to use
its direct connection. pending, unknown, a POST timeout/reset, or an
unexpected response are ambiguous and must never fall back to another
pay_invoice. Wallet rejections are terminal.
GET /v1/nwc/payments/:requestId
Returns the same response contract from the durable journal without publishing
another payment. For pending/unknown, the listener may run read-only
lookup_invoice; matching payment_sent notifications also resolve the row.
Both paths require a preimage whose SHA-256 equals the stored payment hash.
On listener restart, interrupted rows before the dispatch boundary become
not_started; rows at or beyond it become unknown. They are reconciled,
never automatically republished. Request IDs remain as durable idempotency
tombstones; EVENT_RETENTION_DAYS applies to event/feed history, not payments.
POST /nwc/request
Bearer-authenticated. Executes a general NWC call over the already-open relay
socket for non-card operations and compatibility. Request
(nwcProxyRequestSchema):
{
connectionString: string // nostr+walletconnect://… — the pool key
walletId?: string // correlation/logging only
method: 'get_info' | 'get_balance' | 'pay_invoice' | 'make_invoice'
| 'lookup_invoice' | 'list_transactions'
params: Record<string, unknown> // raw NIP-47 params, msat-speaking
timeoutMs?: number // 1000–120000, default 30000
}The request keys on connectionString, not walletId: the caller's NWC
driver only holds the wallet config, and the listener reads the exact same
column from the same Postgres — no new trust boundary. Params and results are
raw NIP-47 (msats); unit conversion stays in web's NWC driver. Bodies over
64 KB are rejected with 413.
Response (nwcProxyResponseSchema):
{ "ok": true, "result": { "...": "raw NIP-47 result" } }{
"ok": false,
"error": {
"code": "wallet_error",
"walletErrorCode": "INSUFFICIENT_BALANCE",
"message": "…"
}
}| Error code | HTTP | Meaning | Caller may fall back to direct NWC? |
|---|---|---|---|
validation_error | 400 | Bad request body | yes |
wallet_not_found | 404 | No pooled connection for that string | yes |
wallet_not_connected | 503 | Pool entry connecting / errored | yes |
wallet_error | 502 | The wallet's own NIP-47 rejection (walletErrorCode) | no — final |
timeout | 504 | No reply within the window | yes |
relay_error | 502 | Other transport failure | yes |
Legacy callers map transport-level codes (wallet_not_found,
wallet_not_connected, timeout, relay_error) to their existing fallback
policy. wallet_error is a decision made by the wallet itself and must never
be retried through a second transport.
/nwc/request remains available for non-payment methods and older web
deployments. New card payments use /v1/nwc/payments specifically so an
ambiguous timeout cannot cause a second payment through the direct path.
Webhook: POST /api/webhooks/nwc (listener → web)
Every new payment notification is delivered to the web app at
POST {WEB_ORIGIN}/api/webhooks/nwc. This is an internal machine-to-machine
contract — deliberately absent from the public OpenAPI spec.
Signature
| Header | Value |
|---|---|
X-LaWallet-Timestamp | Unix milliseconds at signing time |
X-LaWallet-Signature | sha256=<lowercase hex HMAC-SHA256(secret, "<timestamp>.<rawBody>")> |
The secret is the shared LISTENER_AUTH_SECRET. The receiver verifies with a
constant-time compare (the sha256= prefix is optional on verification) and
rejects requests whose clock skew exceeds ±5 minutes.
Payload
A discriminated union on type (nwcWebhookPayloadSchema). All variants
share the base fields:
{
eventKey: string // sha256("<walletId>|<type>|<payment_hash>") — idempotency key
walletId: string // RemoteWallet.id (optional for listener_error)
receivedAt: number // unix ms when the listener first saw the event
recovered?: boolean // true when synthesized by downtime catch-up
}payment_received / payment_sent add a payment object:
{
"type": "payment_received",
"eventKey": "9f2c…64-hex…a1",
"walletId": "cmxyz123",
"receivedAt": 1751673600000,
"payment": {
"paymentHash": "…64-char hex…",
"preimage": "…hex…",
"amountMsats": 21000,
"feesPaidMsats": 0,
"settledAt": 1751673599,
"invoice": "lnbc210n1…",
"description": "coffee",
"transaction": { "...": "raw Nip47Transaction passthrough" }
}
}Only paymentHash and transaction are guaranteed; the rest is optional and
lifted verbatim from what the wallet reported (settledAt is unix seconds,
per NIP-47). The raw notification always travels untouched in transaction —
the web app owns all business interpretation.
listener_error reports connection-level problems (here walletId is
optional, since an error may not belong to a single wallet):
{
"type": "listener_error",
"eventKey": "…",
"receivedAt": 1751673600000,
"error": { "code": "subscription_failed", "message": "…" }
}Semantics
- Idempotent — the derived
eventKeyis stable across relays replaying the same notification as different Nostr events, and across live + recovery delivery. Replays return200 {"received": true}with no side effects. - Responses:
200accepted ·401bad signature or timestamp skew ·400schema mismatch ·404the listener integration is disabled in Settings. - Retries: the listener attempts delivery inline with backoff
(1s → 2min, up to
WEBHOOK_MAX_ATTEMPTS), then a sweep every 5 minutes re-dispatches undelivered events with per-event exponential backoff (2min → 1h cap). There is no attempt cap — a payment webhook keeps retrying until web accepts it, so an arbitrarily long web outage heals with no data loss and no dead-letter queue.counters.webhooksPendingreports the live backlog (0 = caught up); a stuck backlog logswebhook.backlog.
Test connection (admin)
The Test connection button in Settings → NWC Services calls
POST /api/settings/listener-probe (settings-write permission) on the web
app, which probes the listener's authenticated /status:
// Request
{ url: string, secret?: string } // secret omitted → the stored/env secret
// Response
{ ok: true, uptimeSeconds: number, connections: number, relays: number }
| { ok: false, code: 'unreachable' | 'unauthorized' | 'invalid_response' | 'no_secret', error: string }unauthorized (the listener answered 401 — secret mismatch) is deliberately
distinct from unreachable (network/DNS/timeout), so the operator knows
exactly what to fix.
Configuration
The pairing is configured in Admin → Settings → NWC Services (listener
URL + shared secret + enable toggle, stored in the Settings DB) with the
LISTENER_URL / LISTENER_AUTH_SECRET environment variables as deployment
defaults. Docker Compose leaves them blank unless the operator enables the
optional listener profile; appliance bundles may provision them explicitly.
Settings values override the environment; the toggle can force the
integration off either way.
Env-managed deployments may additionally set LISTENER_REQUEST_AUTH_SECRET
on both services to separate web→listener bearer authentication from
listener→web webhook signing. If omitted, request auth falls back to
LISTENER_AUTH_SECRET; the existing Settings secret remains the compatibility
shared credential.
The admin dashboard at Admin → NWC Listener (visible only while the integration is enabled) shows active relays, live NWC connections, recent events and webhook delivery state.
Deployment
Ships as its own container (apps/listener/Dockerfile) behind the optional
Docker Compose listener profile. For Vercel/Netlify deployments it
must be hosted separately (Railway, Render, Fly.io, a VPS) — see the
NWC Listener Setup guide.
Reference
Ops-level detail (dedup storage DDL, catch-up rules, environment table,
failure modes):
docs/services/NWC-LISTENER.md.
Cross-service schemas live in packages/shared/src/listener.ts.