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).
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 |
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.
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"
}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.
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.
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.
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.
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).
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.
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).
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. |
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.
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.
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.
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.
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.
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.
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 > 255 → Response
400:
{ "error": "Not a valid Lightning Address (expected name@domain)" }Well-known resolve fails, metadata lacks zap support, or the mint probe is
unreachable → Response 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.
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:nulllightningAddressVerified:false
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.
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.
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 > 255 → Response 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.
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. |
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_zap → Response 400
{ "error": "This Wallet of Satoshi address cannot receive these Bitcoin payments" }
(no new address in that request is saved). Mint probe unreachable → Response 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.
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.
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.
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. |
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. |
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. |
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. |
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>" }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" }Bearer session. Body { "endpoint": "https://…" }. Removes that device
for this account only.
No/invalid session → 401. VAPID not configured → 503. Missing or
blank endpoint → 400 { "error": "Invalid subscription" }. Unknown
endpoint for this account → 404 { "error": "Not found" }.
Success → Response 200:
{ "ok": true }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 accountId → 400 { "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.
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" }.
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" }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.
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: Bearer → 401 { "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.
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/preimage → 400
{ "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>" }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 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 photo → Response 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_576 → Response 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"
}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" }.
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 id → Response 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.
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 id → Response 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.
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": [].
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"
}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"
}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"
}
]
}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).
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"
}
]
}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).
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.
- 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)