Skip to content

Latest commit

 

History

History
2401 lines (1861 loc) · 97.3 KB

File metadata and controls

2401 lines (1861 loc) · 97.3 KB

21.gifts — API Specification

Canonical description of the HTTP surface implemented by this service. Product decisions live in CONCEPT.md; this file owns request/response contracts for routes that exist in code today.

Status: living document. Last revised 2026-09-14 (Internationalization out-of-scope bullet: api responses and push payloads stay English; visitor-UI locales live in the app catalog. Conversation JSON includes lastFromMe/fromMe; GET /conversations omits empty and outbound-only Direct/Damus threads).


Implemented HTTP surface (normative)

Auth state uses InMemoryAuthStore when DATABASE_URL is unset (tests and local boots). When DATABASE_URL is set, the process migrates the auth schema and uses PostgresAuthStore — accounts, passkey challenges, passkey credentials, sessions, and pending address verifications survive a restart. account.linking_key is nullable for passkey-created rows. A missing or unreachable database URL that is set is fail-loud at boot. On the SQL path, NOSTR_NSEC_KEK (64 lowercase hex) is also required; missing or malformed KEK throws at boot. Public gift statistics (GET /gifts/stats and GET /gifts?day=) read the gift table when DATABASE_URL is set; without it the process still boots and returns empty stats. Amounts are also expressed as BTC and historical USD using the UTC-calendar-day BTC-USD daily close from Coinbase Exchange (persisted in btc_usd_daily), plus additive CHF/EUR/PHP (USD × that UTC day's Frankfurter ECB rate, persisted in usd_fiat_daily; last business day if the market is closed). GET fetches Coinbase only for missing gift days, UTC-today when fetched_at is older than one hour, and a past day whose fetched_at is still on that same UTC calendar day (intraday print not yet the settled close). Settled stored days are not re-fetched. A missing BTC-USD rate after ensure/fetch is 503. A missing CHF/EUR/PHP cross is JSON null, never 503.

Lightning Address verification HTTP routes are implemented. A live verification payment requires an injected invoice payer; the default UnconfiguredInvoicePayer makes start verification return 503. Public GET /lightning-address resolves LUD-16 metadata with an in-memory cache; it does not fetch or pay invoices.

Spend-worker invoice routes (GET /invoices/passkey, GET /invoices/posted, POST /invoices, POST /invoices/proof) check passkey eligibility and a live forum post, fetch a BOLT11 via LNURL-pay, and accept a preimage proof. Issue requires a passkey-backed account for the address and at least one live forum message that is not the auto-created profile note. They require SPEND_API_TOKEN; when it is unset the routes return 503 and the process still boots. This service does not pay invoices (no LNDHub client). A matching proof inserts an outbound row into gift when DATABASE_URL is set (no-op without it) so GET /gifts/stats and GET /gifts?day= include the payment. Insert failure logs gifts.record_failed and still returns 200.

CORS allows the configured origins (CORS_ALLOWED_ORIGINS, or the default surfaces https://21.gifts, https://dev.21.gifts, https://app.21.gifts, https://dev-app.21.gifts, and http://localhost:3000) and methods GET, POST, DELETE, OPTIONS, with headers Authorization and Content-Type. Sessions are sent as Authorization: Bearer headers — no cookies, credentials not enabled.

Public base URLs used in examples:

Environment API App
PRD https://api.21.gifts https://21.gifts
DEV https://dev-api.21.gifts https://dev.21.gifts
Method Path Auth Purpose
GET /healthz none Liveness
GET /info none Service identity
GET /favicon.ico none Brand mark (favicon)
GET /favicon.svg none Brand mark (SVG favicon)
GET /apple-touch-icon.png none Brand mark (Apple touch icon)
POST /auth/passkey/register/begin none Issue WebAuthn creation options
POST /auth/passkey/register/finish none Verify attestation, issue session
POST /auth/passkey/authenticate/begin none Issue WebAuthn request options
POST /auth/passkey/authenticate/finish none Verify assertion, issue session
GET /me Authorization: Bearer Account (setup + factual missing + hasPosted)
GET /me/activity Bearer Given + received series (forum zaps + house gifts; platform given = all outbound)
GET /view/:viewKey none Public profile card by view key
GET /view/:viewKey/activity none Public given/received payload for the account behind the view key
POST /me/setup/skip Bearer Skip name or Lightning Address wizard step
POST /me/name Bearer Set/replace display name (profile note when name + LN are both set)
POST /me/location Bearer Set, change, or clear free-text profile location
POST /me/forum-laws-dismissed Bearer Dismiss welcome-forum living-room laws
POST /me/rules-agreement Bearer Record living-room rules agreement
POST /me/lightning-address Bearer Link/replace after live LNURL resolve + NIP-57 mint probe
DELETE /me/lightning-address Bearer Unlink address (clears LN skip)
POST /me/lightning-address/verification Bearer Start address proof-of-control payment
POST /me/lightning-address/verification/confirm Bearer Confirm nonce from wallet history
GET /members/:accountId Bearer Live member identity + profile note + uncapped counts
GET /members/:accountId/activity Bearer Same given/received payload as /me/activity for that member
GET /members/:accountId/posts Bearer Live member top-level notes (latest 200)
GET /members/:accountId/replies Bearer Live member replies (latest 200)
GET /messages Bearer List top-level forum notes (+ 21.gifts-author replyCount); 409 if rules missing
POST /messages Bearer Post text/photo; 409 if rules/name/Lightning Address missing
GET /messages/:id none Public single-note JSON (404 for Damus-only replies)
GET /messages/:id/replies Bearer Oldest-first 21.gifts-author replies for a parent note
GET /messages/:id/photo none Fetch forum message photo bytes
GET /messages/:id/video.* none Fetch forum video bytes (Range / 206)
DELETE /messages/:id Bearer (founder/moderator) Soft-hide note + direct replies (deleted_at / deleted_by)
POST /messages/:id/invoice Bearer NIP-57 zap / BOLT11
POST /contact Bearer Send private in-app contact { text }
GET /conversations Bearer List visible private threads
POST /conversations Bearer Open thread from a forum note (forumMessageId)
GET /conversations/:id Bearer Oldest-first messages in one thread
POST /conversations/:id Bearer Send { text } in a private thread
GET /notifications Bearer List recipient notifications + unreadCount
POST /notifications/read-all Bearer Mark all notifications read
POST /notifications/:id/read Bearer Mark one notification read
GET /lightning-address none Resolve LUD-16 metadata (cached)
GET /debug/accounts Authorization: Bearer Operator account listing (DEBUG_TOKEN)
POST /debug/accounts Authorization: Bearer Operator provision name + Lightning Address (DEBUG_TOKEN)
PATCH /debug/accounts/:id Authorization: Bearer Operator set role / unlink Lightning Address / platform (isPlatform)
POST /debug/accounts/:id/session Authorization: Bearer Operator mint of a member bearer (DEBUG_TOKEN)
GET /debug/contacts Authorization: Bearer Operator contact listing (DEBUG_TOKEN)
GET /debug/invoices Authorization: Bearer Operator forum invoice attempts (DEBUG_TOKEN)
GET /debug/zap-ingests Authorization: Bearer Operator kind:9735 ingest log (DEBUG_TOKEN)
GET /debug/messages Authorization: Bearer Operator forum listing including hidden rows and replies (DEBUG_TOKEN)
GET /debug/messages/:id Authorization: Bearer Operator single-note fetch including hidden rows (DEBUG_TOKEN)
GET /debug/messages/:id/photo Authorization: Bearer Operator photo bytes including hidden notes (DEBUG_TOKEN)
PUT /debug/messages/:id/video Authorization: Bearer Operator restore of missing forum-video bytes (DEBUG_TOKEN)
GET /push/vapid-public Bearer VAPID public key for Web Push subscribe
POST /me/push-subscriptions Bearer Upsert a browser PushSubscription
DELETE /me/push-subscriptions Bearer Remove a browser PushSubscription
POST /debug/push-ping Bearer DEBUG_TOKEN Enqueue a test push for one account
GET /gifts none Outbound gifts for one UTC day (?day=)
GET /gifts/stats none Aggregated outbound gift statistics
GET /invoices/passkey Bearer SPEND_API_TOKEN Whether a Lightning Address has a passkey-backed account
GET /invoices/posted Bearer SPEND_API_TOKEN Whether a Lightning Address has a live non-profile forum post
POST /invoices Bearer SPEND_API_TOKEN Fetch a recipient BOLT11 (LNURL-pay; passkey and forum post required)
POST /invoices/proof Bearer SPEND_API_TOKEN Accept payment preimage as proof

GET /healthz

Liveness probe. No I/O; always succeeds when the process is up.

Response 200:

{
  "status": "ok",
  "service": "21gifts-api",
  "version": "0.1.0"
}

version is SERVICE_VERSION (env) or "0.1.0" when unset.

GET /info

Service identity for clients. Does not expose runtime configuration.

Response 200:

{
  "service": "21gifts-api",
  "version": "0.1.0",
  "description": "Backend for 21.gifts — peer-to-peer Bitcoin Lightning donations with NOSTR-native communication.",
  "repository": "https://github.com/21gifts/api"
}

GET /favicon.ico

Brand mark for browsers that request /favicon.ico without HTML. No auth. No JSON. No env vars. No Open Graph tags.

Response 200: binary body, Content-Type: image/x-icon, Cache-Control: public, max-age=86400.

Response 404: empty body when the file is missing.

GET /favicon.svg

SVG brand mark at the origin root. No auth. No JSON. No env vars. No Open Graph tags.

Response 200: binary body, Content-Type: image/svg+xml, Cache-Control: public, max-age=86400.

Response 404: empty body when the file is missing.

GET /apple-touch-icon.png

Apple touch icon at the origin root. No auth. No JSON. No env vars. No Open Graph tags.

Response 200: binary body, Content-Type: image/png, Cache-Control: public, max-age=86400.

Response 404: empty body when the file is missing.

POST /auth/passkey/register/begin

Starts a discoverable-credential registration. Empty body mints a new account id (no row until finish). Optional JSON { "viewKey": "<64 lowercase hex>" } claims an existing provisioned account: 404 when the profile is missing, 409 when it already has a passkey, 400 when viewKey is present but not a string.

When WEBAUTHN_RP_ID is unset, blank, not on the allowlist (21.gifts / dev.21.gifts / localhost), or no CORS origin matches that RP ID:

Response 500:

{ "error": "Server auth is not configured" }

Otherwise Response 200:

{
  "challengeId": "<64 hex chars>",
  "options": { "challenge": "<base64url>", "rp": { "id": "21.gifts", "name": "21.gifts" } }
}

options is PublicKeyCredentialCreationOptionsJSON (residentKey and userVerification required, attestation none). user.id is the pending account UUID encoded as UTF-8 (the provisioned account when claiming by viewKey). The process still boots without WEBAUTHN_RP_ID — only these routes fail closed.

POST /auth/passkey/register/finish

Verifies the attestation and issues a session immediately (no poll).

Body:

{ "challengeId": "<hex>", "credential": {} }

credential is the browser RegistrationResponseJSON. The request Origin must be in the RP ID's expected origins (CORS allowlist filtered to that RP ID).

Status Body When
500 { "error": "Server auth is not configured" } RP ID missing, not on the allowlist, or no matching origin
400 { "error": "Expected a JSON body with challengeId and credential" } Body parse fail
400 { "error": "Unknown or expired challenge" } Unknown challengeId
400 { "error": "Challenge expired" } Past challenge TTL
400 { "error": "Challenge already used" } Finish already attempted; challenge is consumed before verification
400 { "error": "Wrong challenge type" } Challenge is not register
400 { "error": "Invalid origin" } Missing or disallowed Origin
400 { "error": "Invalid passkey" } Attestation verify failed or duplicate credential

Response 200:

{
  "token": "<hex>",
  "account": {
    "id": "<uuid>",
    "linkingKey": null,
    "role": "basis",
    "name": null,
    "location": null,
    "lightningAddress": null,
    "lightningAddressVerified": false,
    "forumLawsDismissed": false,
    "viewKey": "<64-hex>",
    "createdAt": 0,
    "rulesAgreedAt": null,
    "setup": "name",
    "missing": ["name", "lightning-address", "rules"],
    "hasPosted": false
  }
}

The account object is the same owner JSON as GET /me (includes viewKey, setup, missing, and hasPosted).

POST /auth/passkey/authenticate/begin

Starts a discoverable-credential assertion. allowCredentials is empty. Same 500 as register begin when WebAuthn is unconfigured.

Response 200: { "challengeId", "options" } where options is PublicKeyCredentialRequestOptionsJSON.

POST /auth/passkey/authenticate/finish

Verifies the assertion against a stored credential, updates signCount, issues a session. A non-increasing signCount is refused as { "error": "Invalid passkey" } except the authenticator 0/0 case. Body shape matches register finish. Extra 400: { "error": "Unknown credential" } when the assertion id is missing or not stored. Success body matches register finish (linkingKey is whatever the account currently has).

GET /me

Returns the account bound to the bearer session.

Missing or invalid bearer → Response 401:

{ "error": "Unauthorized" }

Response 200:

{
  "id": "<uuid>",
  "linkingKey": "<hex>",
  "role": "basis",
  "name": null,
  "location": null,
  "lightningAddress": null,
  "lightningAddressVerified": false,
  "forumLawsDismissed": false,
  "viewKey": "<64-hex>",
  "createdAt": 0,
  "rulesAgreedAt": null,
  "setup": "name",
  "missing": ["name", "lightning-address", "rules"],
  "hasPosted": false
}
Field Type Meaning
id string Opaque account id
linkingKey string | null Historical LNURL-auth linking key (hex), or null for passkey accounts
role string basis, verified, moderator, or founder
name string | null Display name, or null until set
location string | null Free-text location set by the owner, or null when unset. Not unique. Not a setup step.
lightningAddress string | null Linked LUD-16 address, or null
lightningAddressVerified boolean Proof-of-control flag (true only after confirm)
forumLawsDismissed boolean true after the welcome-forum living-room laws hint was dismissed
viewKey string Durable 64 lowercase hex capability secret for GET /view/:viewKey. Owner-only. Not a session.
createdAt number Creation time (epoch ms)
rulesAgreedAt number | null Epoch ms of first living-room rules agreement, or null
setup string | null Next wizard step: name, lightning-address, rules, or null when complete. Skip timestamps count as done. Clients must not invent a parallel sequence.
missing string[] Factually unset fields (name, lightning-address, rules) even when skipped. Does not include profileMessageId.
hasPosted boolean True when this account has a live forum row that is not the auto-created profile note. Same predicate as GET /invoices/posted.

GET /me/activity

Bearer required (same session as GET /me). No living-room-rules gate.

Missing or invalid bearer → Response 401:

{ "error": "Unauthorized" }

Store throw or missing FX day → Response 503:

{ "error": "Gift stats are unavailable" }

Response 200 (empty series when the account has no attributed gifts):

{
  "donatedSats": 0,
  "receivedSats": 0,
  "donatedOverTime": [],
  "receivedOverTime": [],
  "fx": {
    "quote": "BTC-USD",
    "dayBasis": "utc",
    "source": "coinbase-exchange-daily-close",
    "quotes": [{ "code": "USD", "pair": "BTC-USD", "source": "coinbase-exchange-daily-close" }]
  }
}

donatedOverTime / receivedOverTime reuse the spendOverTime day objects from GET /gifts/stats. Given = confirmed forum zaps this account paid, plus every outbound house gift when isPlatform is true. Received = indexed zaps on notes this account authored (including hidden and replies), plus message.sats remainder on top-level notes only (so a visible ₿21 post is never empty; gift-as-reply sats are not Received), plus house gifts to the account Lightning Address handle. Forum zaps are not mixed into GET /gifts/stats.

POST /me/setup/skip

Skip a skippable wizard step. Body:

{ "step": "name" }

or { "step": "lightning-address" }. Sets the matching skip timestamp to now; does not clear name / lightningAddress. step: "rules" and unknown steps are 400. Success → 200 owner JSON.

GET /members/:accountId

Bearer required. :accountId must be a UUID. After auth, requireAction(caller, 'forum.read') — missing rules → 409 { "error": "missing_requirements", "missing": ["rules"] }. Unknown id → 404. Store throw → 503 { "error": "Messages are unavailable" }. Success → live id / name / location / role / lightningAddress / ISO createdAt plus profileMessage (serializeMessage with accountId / replyCount, or null) and uncapped live postCount / replyCount from countByAccount (not the latest-200 window). Never viewKey / eventId.

GET /members/:accountId/posts

Bearer required. Same 401 / 409 / 404 / 503 as GET /members/:accountId (members.posts.failed on 503). Live-only top-level notes by the member, newest-first, capped at 200. Body { "messages": [...] } via serializeMessage like signed-in GET /messages (accountId, replyCount, payable when eventId and a Lightning Address are set). Omits parentId. Replies by that member are not listed.

GET /members/:accountId/replies

Bearer required. Same 401 / 409 / 404 / 503 as GET /members/:accountId (members.replies.failed on 503). Live-only replies by the member, newest-first, capped at 200. Body { "messages": [...] } via serializeMessage with payable false, accountId, and optional parentId when set; omits replyCount. Top-level notes by that member are not listed.

GET /members/:accountId/activity

Same auth and 401 / 409 / 404 as GET /members/:accountId. Success is the same JSON as GET /me/activity for that member. 503 { "error": "Gift stats are unavailable" } when the gift store throws or a gift day lacks FX.

GET /view/:viewKey

Public capability URL for a read-only profile card. No auth. Not a session: the key cannot write, cannot mint a session, and is not accepted as Authorization: Bearer.

Param not matching /^[0-9a-f]{64}$/ or an unknown key → Response 404:

{ "error": "Not found" }

Response 200 (six fields only; omits id, linkingKey, role, viewKey):

{
  "name": null,
  "location": null,
  "lightningAddress": null,
  "lightningAddressVerified": false,
  "createdAt": 0,
  "hasPasskey": false
}

hasPasskey is true when the account has at least one passkey credential, otherwise false. Clients use it to show an activation banner only while the profile is still unclaimed.

GET /view/:viewKey/activity

Public. Same 404 as GET /view/:viewKey for a bad or unknown key. Success is the same JSON as GET /me/activity for the account behind the key. 503 { "error": "Gift stats are unavailable" } when the gift store throws or a gift day lacks FX.

POST /me/name

Set or replace the account display name. Body:

{ "name": "Ada" }

Missing/invalid bearer → Response 401 { "error": "Unauthorized" }.

Body is not JSON with a name string → Response 400:

{ "error": "Expected a JSON body with a \"name\" string" }

Name is empty after trim, longer than 80 characters, or contains a C0 control / DEL character (charCode < 32 or === 127) → Response 400:

{ "error": "Name must be 1–80 characters" }

Success → Response 200 with the updated account (same shape as GET /me). The stored value is trimmed. Names are not unique. When a non-blank Lightning Address is already linked, the first persisted non-empty name also creates exactly one top-level profile forum note and stores profileMessageId (not on owner JSON). Without a Lightning Address the name is stored and no profile note is inserted (linking the address later creates it). Rename does not create a second note and does not change the note text.

POST /me/location

Set, change, or clear the account free-text location. Body:

{ "location": "Berlin" }

Missing/invalid bearer → Response 401 { "error": "Unauthorized" }.

Body is not JSON with a location string → Response 400:

{ "error": "Expected a JSON body with a \"location\" string" }

Location is longer than 80 characters after trim, or contains a C0 control / DEL character (charCode < 32 or === 127) → Response 400:

{ "error": "Location must be at most 80 characters" }

Success → Response 200 with the updated account (same shape as GET /me). Empty or whitespace-only input stores null (clears). The stored non-empty value is trimmed. Location is not unique, not a setup step, and not a posting requirement. It is public on member and view cards. Does not create or update a profile forum note.

POST /me/forum-laws-dismissed

Mark the welcome-forum living-room laws hint as dismissed. No body.

Missing/invalid bearer → Response 401 { "error": "Unauthorized" }.

Success → Response 200 with the updated account (same shape as GET /me), with forumLawsDismissed: true. Already-dismissed accounts return the same shape without a second write (idempotent). There is no un-dismiss.

POST /me/rules-agreement

Record that the signed-in account agreed to the living-room rules. No body is required; any JSON body is ignored.

Missing/invalid bearer → Response 401:

{ "error": "Unauthorized" }

Success → Response 200 with the account (same shape as GET /me). The first successful POST sets rulesAgreedAt to the server clock (epoch ms). Later POSTs return the original timestamp unchanged (idempotent; no 409). New accounts start with rulesAgreedAt: null. Name and Lightning Address link/unlink do not clear the timestamp.

POST /me/lightning-address

Link or replace the receiver Lightning Address. After the LUD-16 shape check, the api live-resolves the well-known LNURL-pay metadata and requires zap support (allowsNostr === true and a non-empty nostrPubkey). It then runs a NIP-57 mint probe (probeNip57Mint with the account's custodial key): a throwaway kind:9734 is signed, an invoice is requested (never paid), and the BOLT11 must be a NIP-57 description_hash invoice. Placeholder, unreachable, or non-zap addresses are rejected and not stored. Body:

{ "address": "name@domain.tld" }

Missing/invalid bearer → Response 401 { "error": "Unauthorized" }.

Body is not JSON with an address string → Response 400:

{ "error": "Expected a JSON body with an \"address\" string" }

Address fails LUD-16 shape check, or trimmed length > 255Response 400:

{ "error": "Not a valid Lightning Address (expected name@domain)" }

Well-known resolve fails, metadata lacks zap support, or the mint probe is unreachableResponse 400 (account unchanged; logs account.lightning_address.resolve_failed):

{ "error": "Lightning Address could not be resolved" }

Mint probe returns not_zap (wallet advertised zap support but the minted invoice is not NIP-57) → Response 400 (account unchanged; logs account.lightning_address.not_zap):

{ "error": "This Wallet of Satoshi address cannot receive these Bitcoin payments" }

Missing NOSTR_NSEC_KEK / nostrKek, key ensure failure, or a missing account pubkey after ensure → Response 503 (account unchanged):

{ "error": "Lightning Address could not be resolved" }

Another account already owns the address (including a unique-index race) → Response 409:

{ "error": "Lightning Address is already in use" }

Success → Response 200 with the updated account (same shape as GET /me). lightningAddressVerified is always reset to false, and any pending verification for the account is cleared. After the address is stored, ensureProfileMessage runs so a non-blank display name that was set earlier gets its profile forum note. There is no proof-of-control in this step — use POST /me/lightning-address/verification for that.

DELETE /me/lightning-address

Unlink the receiver Lightning Address. Also clears any pending verification for the account.

Missing/invalid bearer → Response 401 { "error": "Unauthorized" }.

Success → Response 200 with the updated account:

  • lightningAddress: null
  • lightningAddressVerified: false

POST /me/lightning-address/verification

Start proof-of-control for the linked Lightning Address. No request body.

The api resolves the address via LUD-16 / LNURL-pay, pays 1 sat (or the provider's minSendable if higher, capped at 10 sat) with a one-time nonce in the LUD-12 comment (21gifts <32-hex-nonce>), and stores a pending verification (TTL 15 minutes). The nonce is never returned — the user reads it from their wallet payment history and posts it to confirm.

Missing/invalid bearer → Response 401:

{ "error": "Unauthorized" }

No linked address → Response 409:

{ "error": "No Lightning Address linked" }

Address already verified → Response 409:

{ "error": "Lightning Address already verified" }

No invoice payer configured (default until a real payer is wired) → Response 503:

{ "error": "Verification payments are not configured" }

LNURL-pay resolve/invoice failure, or payment failure → Response 502:

{ "error": "Lightning Address did not accept the verification payment" }

Success → Response 200:

{ "status": "sent", "expiresInSeconds": 900, "sats": 1 }
Field Meaning
status Always "sent" on success
expiresInSeconds Seconds until the pending record expires
sats Amount paid, in sats (payMsat / 1000; fractional if minSendable is not a whole sat)

Linking or unlinking the address clears any pending verification.

POST /me/lightning-address/verification/confirm

Confirm proof-of-control with the nonce from the wallet history. Body:

{ "nonce": "<32 hex chars>" }

Missing/invalid bearer → Response 401:

{ "error": "Unauthorized" }

Body is not JSON with a nonce string → Response 400:

{ "error": "Expected a JSON body with a \"nonce\" string" }

Empty nonce after trim, or nonce does not match → Response 400:

{ "error": "Incorrect verification code" }

No pending verification (or address no longer matches the record) → Response 409:

{ "error": "No verification in progress" }

Pending verification past the TTL → Response 409:

{ "error": "Verification expired" }

Success → Response 200 with the updated account (same shape as GET /me), with lightningAddressVerified: true. The pending record is deleted.

GET /lightning-address

Public LUD-16 metadata resolve for a future guest Donate flow. The api is not in the payment path: this route returns cached well-known LNURL-pay metadata only. It never fetches a BOLT11 invoice (pr) and never pays.

Query parameter:

Param Required Meaning
address yes Lightning Address (name@domain.tld)

The value is normalised with the same LUD-16 shape check as POST /me/lightning-address (trim; length ≤ 255; local@domain.tld).

Missing, empty, not LUD-16, or length > 255Response 400:

{ "error": "Not a valid Lightning Address (expected name@domain)" }

Well-known fetch / JSON / schema failure, non-HTTPS callback, or network error → Response 502:

{ "error": "Lightning Address could not be resolved" }

Success → Response 200:

{
  "address": "name@domain.tld",
  "callback": "https://…",
  "minSendable": 1000,
  "maxSendable": 100000000000,
  "commentAllowed": 255
}
Field Type Meaning
address string Normalised query value
callback string LNURL-pay callback URL (https: only)
minSendable number Minimum sendable amount, millisatoshis
maxSendable number Maximum sendable amount, millisatoshis
commentAllowed number Optional; omitted when the provider did not send it

Cache: successful resolves are stored in process memory for 5 minutes (LN_ADDRESS_CACHE_TTL_MS). A cache hit does not call the provider. Process restart clears the cache. There is no durable (Postgres) cache yet. No auth. No new environment variables for this route; the process still boots with zero extra config when DATABASE_URL and DEBUG_TOKEN are unset.

GET /debug/accounts

Operator listing of every stored account. Authenticated with Authorization: Bearer matching DEBUG_TOKEN. This is not an end-user session. Session tokens and verification nonces are never returned.

DEBUG_TOKEN unset or blank → Response 503:

{ "error": "Debug is not configured" }

Missing or non-matching bearer → Response 401:

{ "error": "Unauthorized" }

Success → Response 200:

{
  "accounts": [
    {
      "id": "<uuid>",
      "linkingKey": "<hex>",
      "role": "basis",
      "name": null,
      "location": null,
      "lightningAddress": null,
      "lightningAddressVerified": false,
      "forumLawsDismissed": false,
      "createdAt": 0,
      "rulesAgreedAt": null,
      "isPlatform": false
    }
  ]
}

The listing uses serializeDebugAccount (the ten public fields plus isPlatform) and never includes viewKey. Member GET /me does not include isPlatform.

Accounts are ordered by createdAt ascending, then id. An empty store returns "accounts": [].

Environment:

Variable Meaning
DATABASE_URL When set, auth state is stored in Postgres; when unset, in-memory only.
DEBUG_TOKEN Operator bearer for this route. Unset → 503; process still boots.

POST /debug/accounts

Operator provision of accounts by display name and Lightning Address, with no passkey and rulesAgreedAt null. Same DEBUG_TOKEN bearer as GET. All new addresses are NIP-57 mint-probed (probeNip57Mint with an ephemeral key) first; only then is any row persisted. Set NIP57_PROBE=0 to skip that probe (e2e only; Playwright pins it). Unset in production so every new address is still probed. One failing new-address probe is 400 and no new address in that request is saved. Name-only updates (address already in the store) do not probe and run after every probe has passed.

Request JSON { "accounts": [ { "name": string, "lightningAddress": string } ] } (1–100 rows; name 1–80 after trim; address has exactly one @ with both sides non-empty). Invalid body, C0/DEL in a name, or an address that is not LUD-16 → Response 400 { "error": "Expected a JSON body with an \"accounts\" array" } (no row is written). Mint probe not_zapResponse 400 { "error": "This Wallet of Satoshi address cannot receive these Bitcoin payments" } (no new address in that request is saved). Mint probe unreachableResponse 400 { "error": "Lightning Address could not be resolved" } (no new address in that request is saved). Create that does not persist the address, a name-only update that matches no row, or a name-only update that returns a row whose name is not the requested name → Response 500 { "error": "Could not save the account" }.

Success → Response 200:

{
  "accounts": [
    {
      "name": "Ada",
      "lightningAddress": "guest@walletofsatoshi.com",
      "viewKey": "<64 lowercase hex>",
      "created": true
    }
  ]
}

Existing address (lower(trim)): updates only name (atomic name-only write; viewKey, role, rulesAgreedAt, and other columns stay unchanged), created is false. New address: fresh viewKey, created is true. GET still omits viewKey.

PATCH /debug/accounts/:id

Operator assignment of the account's forum display role, unlinking the Lightning Address, and/or the official platform flag (isPlatform). Authenticated with Authorization: Bearer matching DEBUG_TOKEN (same gate as GET /debug/accounts). Body is one or more of role, lightningAddress: null, and platform:

{ "role": "basis", "lightningAddress": null, "platform": true }

role must be one of basis, verified, moderator, or founder. lightningAddress may only be JSON null (unlink). platform is a boolean; true clears any other platform flag (at most one isPlatform account) and, when a conversation store is wired, points every member_platform thread at this account except a thread whose member is already this account. Setting a new address is not supported here (POST /me/lightning-address remains the live resolve path). Unlink resets lightningAddressVerified to false and drops any in-flight verification. GET /me then returns setup: "lightning-address" when a name is already stored, so any client that follows setup (or a missing lightningAddress) shows the address form. verified as a role is a human-identity badge (a moderator physically met the person); it is not lightningAddressVerified. New passkey accounts stay basis until an operator changes them here.

DEBUG_TOKEN unset or blank → Response 503:

{ "error": "Debug is not configured" }

Missing or non-matching bearer → Response 401:

{ "error": "Unauthorized" }

Body is not JSON with a known role, lightningAddress: null, and/or platform boolean → Response 400:

{
  "error": "Expected a JSON body with a \"role\" string, lightningAddress null, and/or platform boolean"
}

Unknown account id → Response 404:

{ "error": "Not found" }

Success → Response 200 with the updated account JSON (same serializeDebugAccount shape as GET /debug/accounts, including isPlatform; no viewKey). Role changes log debug.accounts.role_set with the account id and new role. Unlink logs debug.accounts.lightning_address.cleared with the account id (never the token or the previous address). Platform changes log debug.accounts.platform_set with the account id and the new flag.

POST /debug/accounts/:id/session

Operator mint of a member bearer for the given account id. Authenticated with Authorization: Bearer matching DEBUG_TOKEN. Response { "token": "<hex>" }. Unknown account id → 404 { "error": "Not found" }. Same 503/401 gate as the other debug account routes. Not a member login path; for e2e and operator debugging.

GET /debug/contacts

Operator listing of private in-app contact messages. Authenticated with Authorization: Bearer matching DEBUG_TOKEN. This is not an end-user session. Contacts are never listed on a member-facing route.

DEBUG_TOKEN unset or blank → Response 503:

{ "error": "Debug is not configured" }

Missing or non-matching bearer → Response 401:

{ "error": "Unauthorized" }

Store failure → Response 503:

{ "error": "Contact is unavailable" }

Success → Response 200:

{
  "contacts": [
    {
      "id": "<uuid>",
      "accountId": "<uuid>",
      "name": "Ada",
      "text": "Hello",
      "createdAt": "2026-08-29T12:00:00.000Z"
    }
  ]
}

Contacts are newest-first (createdAt descending, then id), capped at 200. An empty mailbox returns "contacts": []. When DATABASE_URL is unset the default in-memory store starts empty; when set, rows come from Postgres contact.

Environment:

Variable Meaning
DATABASE_URL When set, contacts are stored in Postgres; when unset, in-memory only.
DEBUG_TOKEN Operator bearer for this route. Unset → 503; process still boots.

GET /debug/invoices

Operator listing of forum POST /messages/:id/invoice attempts. Authenticated with Authorization: Bearer matching DEBUG_TOKEN. This is not an end-user session.

DEBUG_TOKEN unset or blank → Response 503:

{ "error": "Debug is not configured" }

Missing or non-matching bearer → Response 401:

{ "error": "Unauthorized" }

Store failure → Response 503:

{ "error": "Messages are unavailable" }

Success → Response 200:

{
  "invoices": [
    {
      "id": "<uuid>",
      "createdAt": "2026-08-30T12:00:00.000Z",
      "messageId": "<uuid>",
      "payerAccountId": "<uuid>",
      "authorAccountId": "<uuid>",
      "amountSats": 21,
      "lightningAddress": "user@walletofsatoshi.com",
      "zapRequest": { "kind": 9734 },
      "result": "ok",
      "httpStatus": 200,
      "pr": "lnbc21n1...",
      "paymentHash": "<64-hex>",
      "description": null,
      "descriptionHash": "<64-hex>",
      "isNip57Invoice": true,
      "lnurlResponse": { "pr": "lnbc21n1...", "status": "OK" }
    }
  ]
}

lnurlResponse is the raw LNURL callback JSON object, or null when none was stored. Rows are newest-first, capped at 200. Never includes nsec. result is one of ok, noZap, not_zap, unreachable, no_event, no_author, no_key, sign_failed, rate_limited, bad_body, not_found. isNip57Invoice is true only when descriptionHash equals SHA-256 of the zap-request JSON string sent as LNURL nostr=. Failure rows have pr null and isNip57Invoice false, except not_zap which stores the rejected BOLT11 (pr set, isNip57Invoice false). When DATABASE_URL is unset the in-memory store starts empty.

Environment:

Variable Meaning
DATABASE_URL When set, attempts are stored in Postgres message_invoice.
DEBUG_TOKEN Operator bearer for this route. Unset → 503; process still boots.

GET /debug/zap-ingests

Operator listing of kind:9735 ingest decisions (indexed or rejected). Authenticated with Authorization: Bearer matching DEBUG_TOKEN. This is not an end-user session.

DEBUG_TOKEN unset or blank → Response 503:

{ "error": "Debug is not configured" }

Missing or non-matching bearer → Response 401:

{ "error": "Unauthorized" }

Store failure → Response 503:

{ "error": "Messages are unavailable" }

Success → Response 200:

{
  "ingests": [
    {
      "id": "<uuid>",
      "createdAt": "2026-08-30T12:00:00.000Z",
      "receiptId": "<64-hex>",
      "noteEventId": "<64-hex>",
      "messageId": "<uuid>",
      "outcome": "indexed",
      "reason": null,
      "amountSats": 21,
      "receiptPubkey": "<64-hex>",
      "receipt": { "id": "<64-hex>", "kind": 9735 }
    }
  ]
}

Rows are newest-first, capped at 200. Never includes nsec. When DATABASE_URL is unset the in-memory store starts empty.

Environment:

Variable Meaning
DATABASE_URL When set, ingest rows are stored in Postgres nostr_zap_ingest.
DEBUG_TOKEN Operator bearer for this route. Unset → 503; process still boots.

GET /debug/messages

Operator listing of every persisted forum row (top-level and replies, live and soft-hidden). Authenticated with Authorization: Bearer matching DEBUG_TOKEN. Public hide does not apply. Cap 200, newest-first. JSON { "messages": [ … ] } via serializeDebugMessage. Never includes nostrEvent, contentFp, nsec, or photo/video bytes.

DEBUG_TOKEN unset or blank → Response 503:

{ "error": "Debug is not configured" }

Missing or non-matching bearer → Response 401:

{ "error": "Unauthorized" }

Store throw → Response 503:

{ "error": "Messages are unavailable" }

GET /debug/messages/:id

Operator single-note fetch. Soft-hidden rows are 200 with deletedAt / deletedBy / text. Unknown or non-UUID id → Response 404:

{ "error": "Not found" }

Same debug token gate as GET /debug/messages. Body is the debug object (not wrapped). Never includes nostrEvent, contentFp, nsec, or photo/video bytes.

Store throw → Response 503:

{ "error": "Messages are unavailable" }

GET /debug/messages/:id/photo

Operator JPEG/PNG/WebP bytes, including soft-hidden notes. Same Content-Type / Content-Disposition / CORS as public GET /messages/:id/photo. Missing row, no photo, or non-UUID id → Response 404:

{ "error": "Photo not found" }

Same debug token gate as GET /debug/messages.

Store throw → Response 503:

{ "error": "Messages are unavailable" }

PUT /debug/messages/:id/video

Operator restore of missing forum-video bytes for an existing hasVideo row. Authenticated with Authorization: Bearer matching DEBUG_TOKEN. This is not an end-user session. The raw body is decodeForumVideo'd and written under MEDIA_DIR; the handler does not insert a message row or change columns.

DEBUG_TOKEN unset or blank → Response 503:

{ "error": "Debug is not configured" }

Missing or non-matching bearer → Response 401 (checked before the body is read):

{ "error": "Unauthorized" }

Non-UUID or unknown id → Response 404:

{ "error": "Not found" }

Row has no video, or stored MIME missing → Response 409:

{ "error": "Message has no video" }

Decoded type extension does not match the stored MIME → Response 409:

{ "error": "Video type does not match" }

Empty, oversize, or unrecognized bytes → Response 400:

{ "error": "Expected a video body" }

Store or disk write throws → Response 503:

{ "error": "Messages are unavailable" }

Success → Response 204 with an empty body. Public GET /messages/:id/video.mp4 (or .mov / .webm matching the stored type) can then serve the file.

Environment:

Variable Meaning
DEBUG_TOKEN Operator bearer for this route. Unset → 503; process still boots.
MEDIA_DIR Directory the bytes are written to. Required at boot.

GET /push/vapid-public

Bearer session. Returns the VAPID public key the browser needs for pushManager.subscribe. Missing VAPID env → 503 after session check (the process still boots). No cookies.

No/invalid session → Response 401:

{ "error": "Unauthorized" }

VAPID not configured → Response 503:

{ "error": "Push is not configured" }

Success → Response 200:

{ "publicKey": "<url-safe-base64>" }

POST /me/push-subscriptions

Bearer session. Upserts a browser PushSubscription for the account (endpoint unique; rebinds if another account held it).

No/invalid session → 401 { "error": "Unauthorized" }. VAPID not configured → 503 { "error": "Push is not configured" }. Invalid body (endpoint not an https URL, or missing keys.p256dh / keys.auth) → 400 { "error": "Invalid subscription" }.

Success → Response 200:

{ "endpoint": "https://push.example/device", "createdAt": "2026-08-30T12:00:00.000Z" }

DELETE /me/push-subscriptions

Bearer session. Body { "endpoint": "https://…" }. Removes that device for this account only.

No/invalid session → 401. VAPID not configured → 503. Missing or blank endpoint400 { "error": "Invalid subscription" }. Unknown endpoint for this account → 404 { "error": "Not found" }.

Success → Response 200:

{ "ok": true }

POST /debug/push-ping

Operator enqueue of a test notification. Authenticated with Authorization: Bearer matching DEBUG_TOKEN (not an end-user session). JSON body { "accountId": "<uuid>" }. Enqueues at most one outbox row when the account has a stored subscription.

DEBUG_TOKEN unset or blank → 503 { "error": "Debug is not configured" }. Missing or non-matching bearer → 401 { "error": "Unauthorized" }. VAPID not configured → 503 { "error": "Push is not configured" }. Missing accountId400 { "error": "Expected a JSON body with an \"accountId\" string" }. Unknown account → 404 { "error": "Not found" }.

Success → Response 200:

{ "enqueued": 1 }

enqueued is 0 when the account has no subscription.

GET /gifts

Public list of outbound gifts for one UTC calendar day. Query day=YYYY-MM-DD. No auth. The body never includes invoices, fees, or wallet identifiers.

Missing, blank, or impossible day (2026-02-31) → 400 { "error": "Expected a UTC day (YYYY-MM-DD)" }.

When DATABASE_URL is unset the in-memory gift store is empty — 200 with zeros (totalUsd / totalChf / totalEur / totalPhp "0.00"), gifts: [], and fx with USD-only quotes (no Coinbase / Frankfurter). When gifts exist for that day, the api ensures a BTC-USD close for that UTC day and converts each gift at that day's close. CHF/EUR/PHP are USD × that UTC day's Frankfurter ECB rate (last business day if closed). An empty matching set is 200 without Coinbase or Frankfurter. A query failure or a still-missing BTC-USD rate is 503. A missing CHF/EUR/PHP cross is JSON null on the matching total and per-gift amount, never 503.

Response 200 (empty day):

{
  "day": "2026-06-01",
  "giftCount": 0,
  "totalSats": 0,
  "totalBtc": "0.00000000",
  "totalUsd": "0.00",
  "totalChf": "0.00",
  "totalEur": "0.00",
  "totalPhp": "0.00",
  "gifts": [],
  "fx": {
    "quote": "BTC-USD",
    "dayBasis": "utc",
    "source": "coinbase-exchange-daily-close",
    "quotes": [{ "code": "USD", "pair": "BTC-USD", "source": "coinbase-exchange-daily-close" }]
  }
}

Response 200 (one gift; 1000 sats at BTC-USD 100000 and CHF 0.80 / EUR 0.90 / PHP 50):

{
  "day": "2026-06-01",
  "giftCount": 1,
  "totalSats": 1000,
  "totalBtc": "0.00001000",
  "totalUsd": "1.00",
  "totalChf": "0.80",
  "totalEur": "0.90",
  "totalPhp": "50.00",
  "gifts": [
    {
      "paidAt": "2026-06-01T12:00:00.000Z",
      "amountSats": 1000,
      "amountBtc": "0.00001000",
      "amountUsd": "1.00",
      "amountChf": "0.80",
      "amountEur": "0.90",
      "amountPhp": "50.00",
      "recipient": "alice"
    }
  ],
  "fx": {
    "quote": "BTC-USD",
    "dayBasis": "utc",
    "source": "coinbase-exchange-daily-close",
    "quotes": [
      { "code": "USD", "pair": "BTC-USD", "source": "coinbase-exchange-daily-close" },
      { "code": "CHF", "pair": "USD-CHF", "source": "frankfurter-ecb" },
      { "code": "EUR", "pair": "USD-EUR", "source": "frankfurter-ecb" },
      { "code": "PHP", "pair": "USD-PHP", "source": "frankfurter-ecb" }
    ]
  }
}
Field Type Meaning
day string UTC YYYY-MM-DD of the query
giftCount number Number of gifts that UTC day
totalSats number Sum of gift amounts (sats; fees excluded)
totalBtc string totalSats as BTC with eight decimals
totalUsd string Sum of per-gift USD at this day's close ("1.00")
totalChf string or null USD × this day's ECB CHF; "0.00" when empty; null if this day lacks CHF
totalEur string or null USD × this day's ECB EUR; "0.00" when empty; null if this day lacks EUR
totalPhp string or null USD × this day's ECB PHP; "0.00" when empty; null if this day lacks PHP
gifts { paidAt, amountSats, amountBtc, amountUsd, amountChf, amountEur, amountPhp, recipient }[] Ordered by paidAt ascending, then recipient
fx { quote, dayBasis, source, quotes } Always present; quote is BTC-USD; quotes lists USD always and CHF/EUR/PHP when that day has the cross

gifts[] item:

Field Type Meaning
paidAt string ISO-8601 instant (toISOString, UTC Z)
amountSats number Gift amount in sats
amountBtc string Same amount as BTC with eight decimals
amountUsd string USD at this UTC day's close ("1.00")
amountChf string or null CHF at this UTC day's ECB cross, or null when missing
amountEur string or null EUR at this UTC day's ECB cross, or null when missing
amountPhp string or null PHP at this UTC day's ECB cross, or null when missing
recipient string Recipient handle (recipient_wos_user)

Response 503: { "error": "Gift stats are unavailable" } (store failure or missing BTC-USD only; missing fiat is never 503).

GET /gifts/stats

Public aggregated outbound gift statistics. No auth. The body never includes invoices, fees, or wallet identifiers.

When DATABASE_URL is unset the in-memory gift and FX stores are empty — 200 with zeros, empty series, totalBtc "0.00000000", totalUsd / totalChf / totalEur / totalPhp "0.00", and fx with USD-only quotes (no Coinbase / Frankfurter call). When it is set, the process queries the gift table (paid_at, amount_sats, recipient_wos_user only) and ensures a BTC-USD daily close for each gift's UTC calendar day (from btc_usd_daily, fetching Coinbase only for missing days / stale UTC-today / after-midnight finalize of an intraday print). Each gift's sats are converted at that day's close (not spot). CHF/EUR/PHP are USD × that UTC day's Frankfurter ECB rate (last business day if closed; persisted in usd_fiat_daily). A gift day that lacks a cross returns that currency as JSON null; a running total goes null if any selected gift lacks that cross. Gap days in spendOverTime are zero sats/BTC/USD and "0.00" fiat and need no rate. Gap months in byMonth are zero sats/BTC/USD and "0.00" fiat and need no rate. A query failure or a still-missing BTC-USD rate after ensure is 503. A missing CHF/EUR/PHP cross is never 503.

Optional query recipient filters to one Wallet of Satoshi handle (case-insensitive). The value is trimmed first. When the trimmed value contains @ after the first character, the local-part before @ is used; otherwise the whole trimmed string is the handle. Missing or blank (after trim) recipient is unfiltered. An unknown handle is empty 200 (zeros, USD-only fx.quotes) without a Coinbase or Frankfurter call. Rates are ensured only for the selected gifts' UTC days.

Response 200:

{
  "totalSats": 0,
  "totalBtc": "0.00000000",
  "totalUsd": "0.00",
  "totalChf": "0.00",
  "totalEur": "0.00",
  "totalPhp": "0.00",
  "giftCount": 0,
  "recipientCount": 0,
  "firstPaidAt": null,
  "lastPaidAt": null,
  "spendOverTime": [],
  "byRecipient": [],
  "byMonth": [],
  "fx": {
    "quote": "BTC-USD",
    "dayBasis": "utc",
    "source": "coinbase-exchange-daily-close",
    "quotes": [{ "code": "USD", "pair": "BTC-USD", "source": "coinbase-exchange-daily-close" }]
  }
}
Field Type Meaning
totalSats number Sum of gift amounts (sats; fees excluded)
totalBtc string totalSats as BTC with eight decimals
totalUsd string Sum of per-gift USD at each gift's UTC-day close ("1234.56")
totalChf string or null USD × each gift day's ECB CHF; "0.00" when empty; null if any gift day lacks CHF
totalEur string or null USD × each gift day's ECB EUR; "0.00" when empty; null if any gift day lacks EUR
totalPhp string or null USD × each gift day's ECB PHP; "0.00" when empty; null if any gift day lacks PHP
giftCount number Number of outbound gifts
recipientCount number Distinct recipient handles
firstPaidAt string or null ISO-8601 of the earliest gift
lastPaidAt string or null ISO-8601 of the latest gift
spendOverTime { day, sats, cumulativeSats, btc, cumulativeBtc, usd, cumulativeUsd, chf, cumulativeChf, eur, cumulativeEur, php, cumulativePhp }[] UTC days from first through last; gaps are zero sats/BTC/USD and "0.00" fiat
byRecipient { recipient, giftCount, sats, btc, usd, chf, eur, php }[] Sorted by sats descending, then name; fiat null if any gift to that recipient lacks the cross
byMonth { month, giftCount, sats, btc, usd, chf, eur, php }[] UTC YYYY-MM from first through last; gaps are zero sats/BTC/USD and "0.00" fiat
fx { quote, dayBasis, source, quotes } Always present; quote is BTC-USD; quotes lists USD always and CHF/EUR/PHP when any selected gift day has that cross

Response 503:

{ "error": "Gift stats are unavailable" }

503 is store failure or missing BTC-USD only. Missing CHF/EUR/PHP is JSON null, never 503.

GET /invoices/passkey

Spend-worker eligibility check. Query address=name@domain.tld. Same SPEND_API_TOKEN Bearer as POST /invoices (503 unconfigured / 401 unauthorized).

Missing or invalid Lightning Address → 400 { "error": "Not a valid Lightning Address (expected name@domain)" }.

Success is always 200 (never 404 for an unknown address):

{ "hasPasskey": true }

or { "hasPasskey": false } when there is no account for the address or the account has no passkey credential.

GET /invoices/posted

Spend-worker eligibility check. Query address=name@domain.tld. Same SPEND_API_TOKEN Bearer as POST /invoices (503 unconfigured / 401 unauthorized).

Missing or invalid Lightning Address → 400 { "error": "Not a valid Lightning Address (expected name@domain)" }.

Success is always 200 (never 404 for an unknown address):

{ "hasPosted": true }

or { "hasPosted": false } when there is no account for the address or the account has no live forum message other than the auto-created profile note.

POST /invoices

Spend-worker invoice fetch. After address and amount validation, the api requires a 21.gifts account for address that already has a passkey credential and at least one live forum message that is not the auto-created profile note. It then resolves LUD-16, GETs the LNURL-pay callback, decodes the BOLT11, and stores { id, pr, paymentHash } in memory. It does not pay.

Body:

{ "address": "name@domain.tld", "amountMsat": 100000, "comment": "optional" }

comment is optional and at most 255 characters. amountMsat must be an integer in 1000..10000000000.

When SPEND_API_TOKEN is unset or blank:

Response 503:

{ "error": "Spend invoices are not configured" }

Missing or wrong Authorization: Bearer401 { "error": "Unauthorized" }.

Bad JSON, amountMsat outside 1000..10000000000, or comment longer than 255 → 400 { "error": "Expected a JSON body with address and amountMsat" }.

Invalid Lightning Address → 400 { "error": "Not a valid Lightning Address (expected name@domain)" }.

No account for the address, or the account has no passkey credential → 403 (before any LNURL fetch; no invoice is stored):

{ "error": "Passkey required" }

The account has a passkey but no live forum message other than the auto-created profile note → 403 (after the passkey check, before any LNURL fetch; no invoice is stored):

{ "error": "Forum post required" }

LNURL-pay failure, decode failure, or invoice amount mismatch → 502:

{ "error": "Lightning Address did not issue an invoice" }

Success → Response 200:

{
  "id": "<32 hex>",
  "pr": "lnbc…",
  "paymentHash": "<64 hex>",
  "amountMsat": 100000
}

Unpaid invoices expire after 15 minutes (GIFT_INVOICE_TTL_MS). A later POST /invoices sweeps unpaid rows after expiry plus one extra TTL; until then a matching preimage still proves payment. Restart clears the store.

POST /invoices/proof

Spend-worker proof. Body { "id", "preimage" }. Proof is the preimage; sha256(preimage) must equal the stored payment hash.

Same 503/401 as POST /invoices when unconfigured or unauthorized.

Bad JSON or missing id/preimage400 { "error": "Expected a JSON body with id and preimage" }.

Unknown id → 404 { "error": "Invoice not found" } (including after sweep/restart). Matching preimage → 200 even after the 15-minute unpaid TTL, as long as the row is still in memory. Expired unpaid without a matching preimage → 409 { "error": "Invoice expired" }. Hash mismatch on an unexpired invoice → 400 { "error": "Proof does not match invoice" }. Already paid with a different preimage → 409 { "error": "Invoice already paid" }. Same preimage → 200 idempotent.

A matching proof (including the same-preimage idempotent 200) inserts one outbound gift row when DATABASE_URL is set: BOLT11 pr as lightning_invoice, amount floor(msat / 1000) sats, fee 0, recipient handle from the invoice address, description 21gifts daily, source_wallet lightning.space. Without SQL the recorder is a no-op. Insert errors log gifts.record_failed and do not change the HTTP response.

Success → Response 200:

{ "status": "paid", "id": "<id>", "paymentHash": "<64 hex>" }

GET /messages

Public member forum thread. Bearer session required. After auth, requireAction(account, 'forum.read') (rules). Returns only top-level notes (parent_id IS NULL) newest first (createdAt descending, then id), capped at 200. Replies are never listed here — use GET /messages/:id/replies. This is the latest-200 window on the wire; clients must render the thread as a messenger group (oldest at the top, newest at the bottom above the composer), reversing the array for display. Each message exposes the author name snapshotted at post time, text (may be empty when a photo or video is attached), ISO-8601 createdAt, sats (validated Lightning receipts on that note, default 0), payable (true when the note is signed and the author has a Lightning Address), hasPhoto, hasVideo, videoContentType (null when hasVideo is false), live role (the author's current account.role, or "basis" if the author is missing; omitted for Damus-only authors), and replyCount of live 21.gifts-author children (parent_id match, deleted_at null, account_id IS NOT NULL). Damus-only children do not increment it. List JSON never includes photo or video bytes. Signed-in list/replies/create may include accountId (21gifts author id; omitted for Damus-only top-level notes); public GET /messages/:id never includes it. Nostr event ids are never included in the JSON.

Missing/invalid/expired bearer → Response 401:

{ "error": "Unauthorized" }

Missing rules → Response 409:

{ "error": "missing_requirements", "missing": ["rules"] }

Store failure → Response 503:

{ "error": "Messages are unavailable" }

Success → Response 200:

{
  "messages": [
    {
      "id": "<uuid>",
      "name": "Ada",
      "text": "Thank you!",
      "createdAt": "2026-08-28T12:00:00.000Z",
      "sats": 0,
      "payable": false,
      "hasPhoto": false,
      "hasVideo": false,
      "videoContentType": null,
      "role": "basis",
      "replyCount": 0
    }
  ]
}

An empty thread is 200 with "messages": []. When DATABASE_URL is unset the default in-memory store starts empty; when set, rows come from Postgres message. List queries select top-level rows only (parent_id IS NULL), (photo IS NOT NULL) AS has_photo, and a replyCount of live 21.gifts-author children (account_id IS NOT NULL), and must not select the photo bytea column.

The nostr worker, each tick, queries zap relays (space plus the public list, including when NOSTR_PUBLISH_PUBLIC is unset) for kind:9735 receipts whose e tag matches a recent note event_id. A receipt is indexed when the signer pubkey matches the author's LNURL-pay nostrPubkey, the bolt11 amount is at least 1 sat, and the receipt id is new. Indexed receipts increment that row's sats (GET /messages then returns the new total). Kind:1 EVENT frames published to relays are JSON objects, not JSON strings. Inbound kind:1 #e replies are persisted only when the pubkey maps to a 21.gifts account; unknown npubs are skipped (they stay on Nostr).

POST /messages

Post to the public member forum. Bearer session required. JSON body (not multipart) with text and/or one photo, and an optional parent UUID:

{ "text": "", "inReplyTo": "<uuid>", "photo": { "contentType": "image/jpeg", "data": "<base64>" } }

{ "text": "hello" } without photo remains valid. Photo-only posts are allowed (text may be omitted or empty when a photo is present). At least one of (non-empty trimmed text, photo) is required. Optional inReplyTo is a top-level parent message UUID (JSON only; sets parentId for a one-level NIP-10 reply). Missing or non-UUID inReplyTo, a parent that is not in the store, or a parent that is itself a reply (parentId not null) → 404 { "error": "Not found" }. A valid parent where the caller is neither the parent author nor moderator/founder403 { "error": "A reply needs a Bitcoin payment" } (verified is not exempt; pay via POST /messages/:id/invoice instead). Multipart video posts do not accept inReplyTo (they are always top-level).

After auth, requireAction(account, 'forum.post') requires rules agreement, a non-blank display name, and a non-blank Lightning Address (skip timestamps do not satisfy). The api stores a name snapshot (trimmed account name at post time), normalised text (possibly "" for photo-only), optional JPEG/PNG/WebP bytes (≤ 1 MiB; MIME from magic bytes), parentId (null for top-level notes), and a timestamp. Text longer than 500 after trim, or with disallowed C0/DEL controls, is rejected. Newlines (\n, \r) are allowed. The 200 body is the public message object itself (not wrapped in { messages }), including sats, payable, hasPhoto, hasVideo, and videoContentType. May include accountId (21gifts author id). No replyCount, and no photo or video bytes in the JSON. sats is 0 and payable is false until the worker signs the note (and stays false without author LN). role is the posting session account's live account.role. Web Push for a top-level note notifies every other subscribed account. A reply to a 21.gifts-author parent (not the replier themselves) inserts one forum_reply notification for that author and enqueues one targeted push (url /notifications, tag forum_reply:<parentId>). The booted process always has notification and push stores (in-memory without DATABASE_URL, Postgres when it is set). Photo-only empty text still notifies. Self-replies and Damus-only parents do not notify. Notification or push failure does not fail the 200. Over-limit posters get 429 { "error": "Too many messages" } with Retry-After: 10 (1/10s, 6/h, 20/UTC-day). A second live photo/video POST with the same account, parent, normalised text, and media bytes returns 200 with the existing row (no extra burst slot, no second top-level push). Text-only posts are unchanged (still 429 on burst). The worker signs a top-level kind:1 (content includes Damus-visible #bitcoin and #21gifts, and when the author's location is non-null also #<locationHashtagName> plus a t tag (not on the profile note); forum text stays the member's words) and fans out when NOSTR_PUBLISH=1.

Missing/invalid/expired bearer → Response 401:

{ "error": "Unauthorized" }

Missing required fields → Response 409:

{ "error": "missing_requirements", "missing": ["rules", "name", "lightning-address"] }

(missing is never empty; order is rules, then name, then lightning-address. A named, rules-agreed account with null LN yields ["lightning-address"] only.)

Body is not JSON with text and/or photoResponse 400:

{ "error": "Expected a JSON body with text and/or photo" }

Text longer than 500 after trim, or contains a disallowed control → Response 400:

{ "error": "Text must be 1–500 characters" }

Whitespace-only / empty text with no photo → Response 400:

{ "error": "Text must be 1–500 characters or include a photo" }

photo present but invalid base64, wrong magic (not JPEG/PNG/WebP), empty, or decoded size > 1_048_576Response 400:

{ "error": "Photo must be a JPEG, PNG, or WebP under 1 MiB" }

inReplyTo present but not a UUID, the parent is missing, or the parent is itself a reply → Response 404:

{ "error": "Not found" }

Valid parent, but the caller is not the parent author and not moderator/founderResponse 403:

{ "error": "A reply needs a Bitcoin payment" }

Store failure → Response 503:

{ "error": "Messages are unavailable" }

Success → Response 200:

{
  "id": "<uuid>",
  "name": "Ada",
  "text": "Thank you!",
  "createdAt": "2026-08-28T12:00:00.000Z",
  "sats": 0,
  "payable": false,
  "hasPhoto": false,
  "hasVideo": false,
  "videoContentType": null,
  "role": "basis"
}

POST /messages/:id/invoice

Signed-in pay-on-note. Bearer session required. :id is a UUID (MESSAGE_ID_RE). Body { "sats": <int 1..10_000_000>, "text"?: "<string>" }. Optional text is the NIP-57 zap-request content (same 1–500 forum rules; omit or whitespace = gift-only). Invalid text400 { "error": "Text must be 1–500 characters" }. The api signs a NIP-57 zap request with the payer key and returns a BOLT11 invoice for the author Lightning Address only when the minted invoice's description_hash equals SHA-256 of the zap-request JSON (isNip57Invoice). A validated kind:9735 receipt still increments the parent sats. After that increment (never in the same SQL CTE), the worker inserts a reply from the payer (text from the zap-request comment or "", sats = this zap). Gift-only replies (text === "") stay nostrPublishState skipped (no kind:1). Parent sats is the aggregate; reply sats is this gift. After the gift-reply insert, notifyForumReply runs best-effort (notification row and/or /notifications push when those stores are set; it does not copy into the member↔member inbox). Notify failure logs messages.reply.notify.failed and does not undo the receipt or the reply. LNURL success with a non-NIP-57 invoice (plaintext description, missing/mismatched description_hash, or malformed BOLT11) → persist not_zap (with rejected pr for debug) and 400 { "error": "The author's wallet cannot receive this Bitcoin payment" } with no pr in the body. LNURL noZap (author wallet does not advertise zap receive) → same author's-wallet 400 (persist noZap, pr null). Other LNURL/zap transport failures (unreachable) → 400 { "error": "Could not start the Bitcoin payment" }. It does not increment sats (that happens when a validated kind:9735 receipt is indexed). After auth, every attempt with a valid UUID is persisted best-effort to message_invoice (result, HTTP status, pr, description vs description_hash, isNip57Invoice). Store failures log message.invoice.record_failed and do not change the HTTP response. A non-UUID :id is 404 without a persist row.

Success → Response 200:

{ "pr": "lnbc…", "amountSats": 21 }

Missing Bearer → 401 { "error": "Unauthorized" }. Payer missing living-room rules → 409 { "error": "missing_requirements", "missing": ["rules"] }. Malformed body or sats above 10 million → 400 { "error": "Expected a JSON body with a positive \"sats\" integer" }. Unknown id → 404 { "error": "Not found" }. Unsigned note, author without a Lightning Address, or missing recipient pubkey → 400 { "error": "This message cannot be paid yet" }. Missing KEK → 503 { "error": "Messages are unavailable" } (before the limiter). Over-limit → 429 { "error": "Too many payments" } (Retry-After: 10) — checked only after auth, amount, payable, and KEK checks succeed, so early 400/404/401/503 do not consume quota. LNURL/zap or sign failure after the limiter still counts. Author-wallet zap failure (noZap or not_zap) → 400 { "error": "The author's wallet cannot receive this Bitcoin payment" }. Other LNURL/zap failure (unreachable) → 400 { "error": "Could not start the Bitcoin payment" }. Keygen/sign failure → 503 { "error": "Messages are unavailable" }.

GET /messages/:id/photo

Fetch the optional photo bytes for one forum message. No bearer — Damus loads this URL from kind:1 imeta. Missing message, message-without-photo, and a non-UUID id are the same 404 (Postgres would otherwise throw on uuid and become 503).

No photo for idResponse 404:

{ "error": "Photo not found" }

Store failure → Response 503:

{ "error": "Messages are unavailable" }

Success → Response 200: raw image body, Content-Type one of image/jpeg / image/png / image/webp (from stored magic-derived type), Cache-Control: public, max-age=86400. Not JSON.

Photo, video, and replies register before the public single-note GET /messages/:id so /photo, /video.mp4 (and .webm / .mov), and /replies are not captured as an :id.

GET /messages/:id/video.mp4

Fetch optional video bytes for one forum message (same handler for .webm and .mov). No bearer — Damus loads this URL from kind:1 imeta. Missing message, message-without-video, extension that does not match the stored MIME, and a non-UUID id are the same 404. Supports Range / HTTP 206 and 416 (Content-Range: bytes */SIZE).

No video for idResponse 404:

{ "error": "Video not found" }

Store failure → Response 503:

{ "error": "Messages are unavailable" }

Success → Response 200 or 206: raw video body, Content-Type one of video/mp4 / video/webm / video/quicktime, Accept-Ranges: bytes, Cache-Control: public, max-age=86400, Access-Control-Allow-Origin: *. Not JSON.

GET /messages/:id/replies

Bearer session required. Lists direct live 21.gifts-author replies (account_id IS NOT NULL) for parent :id oldest-first (createdAt then id ascending), capped at 200. Unknown-npub (Damus-only) children are omitted. Each item is the public message JSON with payable false and no replyCount. Signed-in replies always include accountId (21gifts author id). Photo and video bytes are never included. :id is a UUID (MESSAGE_ID_RE).

Missing/invalid/expired bearer → Response 401:

{ "error": "Unauthorized" }

:id is not a UUID, the parent is missing, or the parent is soft-hidden → Response 404:

{ "error": "Not found" }

Store failure → Response 503:

{ "error": "Messages are unavailable" }

Success → Response 200:

{
  "messages": [
    {
      "id": "<uuid>",
      "name": "Ada",
      "text": "A reply",
      "createdAt": "2026-08-28T12:01:00.000Z",
      "sats": 0,
      "payable": false,
      "hasPhoto": false,
      "hasVideo": false,
      "videoContentType": null,
      "role": "basis"
    }
  ]
}

An empty reply thread is 200 with "messages": []. Soft-hidden and Damus-only children (accountId null) are omitted from the list.

GET /messages/:id

Public single-note fetch. No Bearer. :id is a UUID. Registered after photo, video, GET /messages/:id/replies, and DELETE /messages/:id so those paths are not captured as :id. Returns the public message JSON (sats, payable, hasPhoto, hasVideo, videoContentType; live role for 21gifts authors). Never includes accountId, deletedAt, or deletedBy. Top-level Damus-only notes (accountId null, parentId null) omit role and set payable false. A live Damus-only reply (parentId set, accountId null) is 404 { "error": "Not found" } (same body as missing/hidden). replyCount is omitted. Photo and video bytes are never included. Soft-hidden rows (deletedAt set) are treated as missing (404) before any missing-video hard-delete cleanup.

Optional query sinceSats (non-negative integer string, /^\d+$/): long-polls until that note's sats is strictly greater than n, then returns the same 200 public JSON as an immediate GET. First read may return immediately when sats is already higher. sats === n keeps waiting. Timeout (~25s) still returns 200 with the current body (never 204/202/304); the client retries. Absent sinceSats is unchanged immediate GET. Invalid sinceSats (-1, 1.5, abc, empty, +1, whitespace) → 400 after the UUID check (non-UUID :id stays 404 even when sinceSats is present). Soft-hidden / missing during the wait (including the first read) → 404. Store throw on any read → 503.

Non-UUID :id, missing row, soft-hidden row, or live Damus-only reply → Response 404:

{ "error": "Not found" }

Invalid sinceSatsResponse 400:

{ "error": "Expected sinceSats to be a non-negative integer" }

Store failure → Response 503:

{ "error": "Messages are unavailable" }

Success (including sinceSats timeout with unchanged sats) → Response 200:

{
  "id": "<uuid>",
  "name": "Ada",
  "text": "Thank you!",
  "createdAt": "2026-08-28T12:00:00.000Z",
  "sats": 0,
  "payable": false,
  "hasPhoto": false,
  "hasVideo": false,
  "videoContentType": null,
  "role": "basis"
}

DELETE /messages/:id

Staff soft-hide. Bearer session required. Live role must be founder or moderator (authors with basis / verified get 403 even on their own post). Stamps deleted_at / deleted_by on the target row and every direct reply that is not yet tagged. Does not hard-delete the Postgres row, photo bytes, on-disk video, invoices, zap receipts, or gift records; does not call deleteById / DELETE FROM message. Already tagged targets keep their original stamps and still return 204. getById continues to return tagged rows for workers; public/member HTTP reads treat them as missing.

Missing/invalid/expired bearer → Response 401:

{ "error": "Unauthorized" }

Live role is not founder and not moderator → Response 403:

{ "error": "Forbidden" }

:id is not a UUID, or no row with that id → Response 404:

{ "error": "Not found" }

Success (including already tagged) → Response 204 empty body.

Store failure → Response 503:

{ "error": "Messages are unavailable" }

On success the process logs messages.deleted with messageId, accountId, and the staff role (never the post text). On store throw it logs messages.delete.failed.

POST /contact

Private in-app contact mailbox. Bearer session required. Body:

{ "text": "" }

The account must already have a non-blank display name. The api stores a name snapshot (trimmed account name at post time), the normalised text, and a timestamp. Text goes through normalizeForumText (newlines \n/\r allowed; other C0 and DEL rejected), then contact still requires trimmed length 1–500. Forum photo-only empty text is not accepted here. The 200 body is the public contact object itself (not wrapped). No accountId in the member-facing JSON. Contacts are never listed publicly — operators still read the mailbox via GET /debug/contacts (DEBUG_TOKEN must not read member PNs). After the platform account exists, the contact row is persisted first, then the same text is appended to the member→platform conversation thread so it is readable via GET /conversations. Conversation append failure logs conversations.contact_sync.failed and still returns 200 (contact is the product surface). When no platform account (isPlatform) exists (neither contact nor thread is written) → Response 503:

{ "error": "Platform account is not configured" }

No email. Outbound Nostr fan-out is the conversation worker (NIP-17 wrap), not this HTTP handler.

Missing/invalid/expired bearer → Response 401:

{ "error": "Unauthorized" }

Body is not JSON with a text string → Response 400:

{ "error": "Expected a JSON body with a \"text\" string" }

Missing required fields (requireAction contact.post) → Response 409:

{ "error": "missing_requirements", "missing": ["rules", "name"] }

Text empty, longer than 500 after trim, or contains a disallowed control → Response 400:

{ "error": "Text must be 1–500 characters" }

Store failure → Response 503:

{ "error": "Contact is unavailable" }

Success → Response 200:

{
  "id": "<uuid>",
  "name": "Ada",
  "text": "Hello",
  "createdAt": "2026-08-29T12:00:00.000Z"
}

GET /conversations

Bearer session required. Nothing public. Lists threads the session may see: own member↔member / member↔Damus / member↔platform threads, plus (when role is founder or moderator) every platform thread. Empty threads and outbound-only member/Damus threads (every stored sender is conversationFromMe for the viewer, including staff-as-platform) are omitted. The member's own member_platform contact thread is listed when it has a message, even if outbound-only. Damus inbound (null sender) is inbound and listed. GET /conversations/:id and POST still return/open outbound-only and empty threads. Newest lastMessageAt first. Cap 200. Member JSON never includes accountId, event ids, or npubs; Damus-only counterpart name may be a truncated npub.

Missing/invalid/expired bearer → Response 401:

{ "error": "Unauthorized" }

Store failure → Response 503:

{ "error": "Conversations are unavailable" }

Success → Response 200:

{
  "conversations": [
    {
      "id": "<uuid>",
      "kind": "member_member",
      "name": "Ada",
      "lastText": "Hello",
      "lastAt": "2026-08-29T12:00:00.000Z",
      "lastFromMe": false
    }
  ]
}

POST /conversations

Bearer session required. Open or return the thread with a forum note's author (21gifts account or Damus pubkey). Body:

{ "forumMessageId": "<uuid>" }

Unknown / non-UUID note → 404 { "error": "Not found" }. Author is the session account → 400 { "error": "Cannot message yourself" }.

Success → Response 200 (same public conversation object as list rows).

GET /conversations/:id

Bearer session required. :id is a UUID. Messages oldest-first (cap 200). 404 { "error": "Not found" } when the id is not a UUID, the thread is missing, or the session may not see it.

Success → Response 200:

{
  "messages": [
    {
      "id": "<uuid>",
      "name": "Ada",
      "text": "Hello",
      "createdAt": "2026-08-29T12:00:00.000Z",
      "fromMe": true
    }
  ]
}

POST /conversations/:id

Bearer session required. Body { "text": "…" } 1–500 via normalizeForumText. Staff (founder | moderator) replies on a platform thread persist as the platform account; the worker signs with the platform nsec. Relay failure does not block local persist.

Same 401 / 400 text / 404 / 503 shapes as the list/get routes, plus 400 { "error": "Set a name before posting" } when the sending member has no display name.

Success → Response 200 (one public conversation message).

GET /notifications

Bearer session required. Lists the recipient's notifications newest-first (cap 200) plus the total unread count (not the page length). Member JSON never includes recipient or actor account ids.

Missing/invalid/expired bearer → Response 401:

{ "error": "Unauthorized" }

Store failure → Response 503:

{ "error": "Notifications are unavailable" }

Success → Response 200:

{
  "notifications": [
    {
      "id": "<uuid>",
      "type": "forum_reply",
      "parentId": "<uuid>",
      "replyId": "<uuid>",
      "name": "Bob",
      "text": "bob reply",
      "createdAt": "2026-09-12T12:00:00.000Z",
      "readAt": null
    }
  ],
  "unreadCount": 1
}

unreadCount is the total unread, not the page length.

POST /notifications/read-all

Bearer session required. Marks every unread notification for the session account read.

Missing/invalid/expired bearer → 401 { "error": "Unauthorized" }. Store failure → 503 { "error": "Notifications are unavailable" }.

Success → Response 200:

{ "ok": true }

POST /notifications/:id/read

Bearer session required. :id is a UUID. Marks one notification read and returns that PublicNotification with readAt set. Unknown id, another account's notification, or a non-uuid :id404 { "error": "Not found" }. Same 401 / 503 as list.

Success → Response 200 (one public notification with readAt set).


Not implemented (v1, decided in CONCEPT — no HTTP paths)

The following are decided product capabilities for v1 (see CONCEPT.md) but are not exposed as HTTP routes in this codebase yet. Paths and JSON for these land in the PR that implements them; this file is updated then. Do not treat the list below as inventing endpoints.

Donor LNDHub credentials. Paying uses lightning.space LNDHub in the external spend worker, not encrypted storage in this api. No /me/donor deposit route.

Recurring daily gifts UI. Donors will configure fixed USD amounts to recipients. Invoice fetch + preimage proof for the external payer is POST /invoices / POST /invoices/proof. No /me/recurring or in-process scheduler.

Feed / discovery / campaign index. Paginated read endpoints over indexed NOSTR events (profiles, campaigns, replies). Not wired yet. Custodial nsec and server-side kind:1 / zap signing ship in this version (KEK + worker).

Readiness probe. /healthz remains liveness-only. A readiness check of downstream dependencies is still planned. The LUD-16 metadata cache on GET /lightning-address is in-memory only. Gift statistics read Postgres when DATABASE_URL is set.

Moderator-only endpoints. Soft-hide is implemented as DELETE /messages/:id (founder/moderator session). Content unhide / UNDELETE and other Moderator actions are not HTTP routes yet. Role values exist on the account model; GET /debug/accounts and PATCH /debug/accounts/:id are operator token routes, not a moderator session.


Out of scope for v1

  • Passkey + PRF + NIP-06 user-owned keys (non-custodial phase)
  • Email/password login (or any second login method)
  • Internationalization of the api (responses and push payloads stay English; visitor-UI locales live in the app catalog)
  • Platform custody of receiver funds (receiving stays LUD-16 only)
  • Arbitrary LNDHub URLs (the external spend worker uses lightning.space only)