Skip to content

Latest commit

 

History

History
1956 lines (1505 loc) · 74.2 KB

File metadata and controls

1956 lines (1505 loc) · 74.2 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-01 (PUT /debug/messages/:id/video).


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). 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 rate after ensure/fetch is 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, POST /invoices, POST /invoices/proof) check passkey eligibility, fetch a BOLT11 via LNURL-pay, and accept a preimage proof. Issue requires a passkey-backed account for the address. 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)
GET /view/:viewKey none Public profile card by view key
POST /me/setup/skip Bearer Skip name or Lightning Address wizard step
POST /me/name Bearer Set/replace display name (first name creates profile note)
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
GET /messages Bearer List top-level forum notes (+ replyCount); 409 if rules missing
POST /messages Bearer Post text/photo; 409 if rules/name missing; LN not required to post
GET /messages/:id none Public single-note JSON
GET /messages/:id/replies Bearer Oldest-first 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)
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 /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)
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
POST /invoices Bearer SPEND_API_TOKEN Fetch a recipient BOLT11 (LNURL-pay; passkey 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,
    "lightningAddress": null,
    "lightningAddressVerified": false,
    "forumLawsDismissed": false,
    "viewKey": "<64-hex>",
    "createdAt": 0,
    "rulesAgreedAt": null,
    "setup": "name",
    "missing": ["name", "lightning-address", "rules"]
  }
}

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

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,
  "lightningAddress": null,
  "lightningAddressVerified": false,
  "forumLawsDismissed": false,
  "viewKey": "<64-hex>",
  "createdAt": 0,
  "rulesAgreedAt": null,
  "setup": "name",
  "missing": ["name", "lightning-address", "rules"]
}
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
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.

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 / role / lightningAddress / ISO createdAt plus profileMessage (serializeMessage with accountId / replyCount, or null). Never viewKey / eventId.

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 (five fields only; omits id, linkingKey, role, viewKey):

{
  "name": 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.

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. The first persisted non-empty name also creates exactly one top-level profile forum note and stores profileMessageId (not on owner JSON). Rename does not create a second note and does not change the note text.

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. 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,
      "lightningAddress": null,
      "lightningAddressVerified": false,
      "forumLawsDismissed": false,
      "createdAt": 0,
      "rulesAgreedAt": null,
      "isPlatform": false
    }
  ]
}

The listing uses serializeDebugAccount (the nine 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.

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, gifts: [], and fx (no Coinbase). 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. An empty matching set is 200 without Coinbase. A query failure or a still-missing rate is 503.

Response 200 (empty day):

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

Response 200 (one gift):

{
  "day": "2026-06-01",
  "giftCount": 1,
  "totalSats": 500,
  "totalBtc": "0.00000500",
  "totalUsd": "0.50",
  "gifts": [
    {
      "paidAt": "2026-06-01T08:00:00.000Z",
      "amountSats": 500,
      "amountBtc": "0.00000500",
      "amountUsd": "0.50",
      "recipient": "alice"
    }
  ],
  "fx": {
    "quote": "BTC-USD",
    "dayBasis": "utc",
    "source": "coinbase-exchange-daily-close"
  }
}
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 ("0.50")
gifts { paidAt, amountSats, amountBtc, amountUsd, recipient }[] Ordered by paidAt ascending, then recipient
fx { quote, dayBasis, source } Always present; Coinbase Exchange daily close, UTC day basis

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 ("0.50")
recipient string Recipient handle (recipient_wos_user)

Response 503: { "error": "Gift stats are unavailable" }.

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 "0.00", and fx present (no Coinbase 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). Gap days in spendOverTime are zero sats/BTC/USD and need no rate. Gap months in byMonth are zero sats/BTC/USD and need no rate. A query failure or a still-missing rate after ensure is 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, fx present) without a Coinbase call. Rates are ensured only for the selected gifts' UTC days.

Response 200:

{
  "totalSats": 0,
  "totalBtc": "0.00000000",
  "totalUsd": "0.00",
  "giftCount": 0,
  "recipientCount": 0,
  "firstPaidAt": null,
  "lastPaidAt": null,
  "spendOverTime": [],
  "byRecipient": [],
  "byMonth": [],
  "fx": {
    "quote": "BTC-USD",
    "dayBasis": "utc",
    "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")
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 }[] UTC days from first through last; gaps are zero sats/BTC/USD
byRecipient { recipient, giftCount, sats, btc, usd }[] Sorted by sats descending, then name
byMonth { month, giftCount, sats, btc, usd }[] UTC YYYY-MM from first through last; gaps are zero sats/BTC/USD
fx { quote, dayBasis, source } Always present; Coinbase Exchange daily close, UTC day basis

Response 503:

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

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.

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. 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" }

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 (direct parent_id children). List JSON never includes photo or video bytes. Signed-in list/replies/create may include accountId (21gifts author id; omitted for Damus-only); 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 direct children, 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.

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" }. Multipart video posts do not accept inReplyTo (they are always top-level).

After auth, requireAction(account, 'forum.post') requires rules agreement and a non-blank display name (Lightning Address is not required to post). 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 is enqueued only when parentId is null (top-level notes); replies do not push. Over-limit posters get 429 { "error": "Too many messages" } with Retry-After: 10 (1/10s, 6/h, 20/UTC-day). The worker signs a top-level kind:1 (content includes Damus-visible #bitcoin and #21gifts; 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"] }

(missing is never empty; order is rules, then name.)

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" }

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> }. 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). 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 replies for parent :id oldest-first (createdAt then id ascending), capped at 200. Each item is the public message JSON with payable false and no replyCount. Signed-in replies may include accountId (21gifts author id; omitted for Damus-only). Damus-only replies (accountId null) omit role. 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, or the parent is missing → 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": [].

GET /messages/:id

Public single-note fetch. No Bearer. :id is a UUID. Registered after photo, video, and GET /messages/:id/replies 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. Damus-only notes (accountId null) omit role and set payable false. replyCount is omitted. Photo and video bytes are never included.

Non-UUID :id or missing row → Response 404:

{ "error": "Not found" }

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 /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. 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>",
      "name": "Ada",
      "lastText": "Hello",
      "lastAt": "2026-08-29T12:00:00.000Z"
    }
  ]
}

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"
    }
  ]
}

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).


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. Content hide/unhide and related Moderator actions. 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 (English only)
  • Platform custody of receiver funds (receiving stays LUD-16 only)
  • Arbitrary LNDHub URLs (the external spend worker uses lightning.space only)