Screen-by-screen sketch of the five journeys named in CONCEPT next-step 7, plus the shipped in-app contact mailbox (journey 6) and in-app Notifications plus Web Push (journey 7). Product decisions live in
CONCEPT.md. Implemented HTTP contracts live inSPEC.md. This file does not invent HTTP paths, JSON fields, or status codes. When a journey has no route inSPEC.md, say so and stop.
Status: living document. Last revised 2026-09-23.
| Label | Meaning |
|---|---|
| Shipped | Exists in 21gifts/app and/or this api today; HTTP only as already named in SPEC.md |
| Sketch | Decided v1 UX from CONCEPT.md; no UI and no HTTP yet. Headings here are not routes. |
Users are never asked about keys, relays, or NOSTR jargon on any screen. The product stays warm and direct — people helping people.
The landing page at / is the marketing site (pitch, how-it-works, FAQ) with
Log in / Ask for help to /login and Send help to /donate.
/login is the passkey-only sign-in surface (LoginCard). Passkey RP ID is
WEBAUTHN_RP_ID.
On mount the app rehydrates a persisted session token via GET /me. A 401
clears the token; a transient failure does not.
Passkey (first login, HTTP shipped)
- App calls
POST /auth/passkey/register/begin(new account, or{ "viewKey" }to claim a provisioned profile) orPOST /auth/passkey/authenticate/begin(returning). - Browser runs
navigator.credentials.create/getwith the returnedoptions(no WebAuthn library in the app). - App posts the credential to the matching
…/finishwith the pageOrigin. The api verifies and returns{ token, account }immediately. An account withsessionRefused(operator flag on the row) is refused at finish and onGET /mewith 403 and no new bearer, so the client can sign the visitor out.
Login is passkey-only. LNURL-auth has been removed.
A recovery phrase is never replaced. POST /auth/passkey/replace/begin and
/finish with a valid Bearer return 409
{ "error": "A recovery phrase cannot be replaced" } and delete nothing.
An account whose walletRequired is not true can add one seed passkey via
POST /auth/passkey/seed/begin then /finish. The login credential stays.
The session stays. walletBackupSeenAt does not decide whether a seed exists.
walletRequired true means a seed passkey exists.
The signed-in view currently lives on /login — there is no separate
/profile route yet. It shows a name form, a username form, a Lightning
Address form, and Sign out. Name and Lightning Address are each
skippable via POST /me/setup/skip. Username cannot skip; the app sets
the handle with POST /me/username. Living-room rules stay required.
An existing member records that the account can show a recovery phrase via
POST /me/wallet-backup-seen after activating a phrase-capable passkey. That
is not a confirmation and not a setup step.
GET /me setup order is name, then username (unskippable), then
lightning-address, then rules. The recovery phrase is not a setup step
and does not change setup or missing. When username is still blank,
POST /me/name auto-assigns usernameFromDisplayName if that handle is
free; a collision or uniqueness race leaves username null and setup at
username.
After name/skip, username, and address/skip,
the app records living-room rules agreement via POST /me/rules-agreement.
GET /me carries setup (wizard; skip counts as done for name and
Lightning Address, not username), missing (facts; skip does
not), walletRequired, walletBackupSeenAt, and rulesAgreedAt (epoch
ms of the first agreement, or null).
No email, no password. Losing the passkey (and platform sync) loses the account.
HTTP cited: /auth/passkey/register/begin, /auth/passkey/register/finish,
/auth/passkey/authenticate/begin, /auth/passkey/authenticate/finish,
/auth/passkey/replace/begin, /auth/passkey/replace/finish,
/auth/passkey/seed/begin, /auth/passkey/seed/finish,
/me, /me/wallet-backup-seen, /me/setup/skip, /me/name, /me/username,
/me/rules-agreement.
Every account can receive. From the signed-in view the user can link, replace, or unlink a LUD-16 Lightning Address:
POST /me/lightning-address— link or replace after a live well-known resolve that requires zap metadata (allowsNostr+nostrPubkey). Always leaves the address unverified. Unreachable or non-zap addresses are rejected and not stored.DELETE /me/lightning-address— unlink (also clears the LN skip timestamp; does not clearusername). After unlink,setupisusernameif the handle is blank;setupislightning-addresswhen name is done or skipped and username is set. The recovery phrase is not a setup step and does not changesetupormissing.
Proof-of-control of the linked Lightning Address is the flag
lightningAddressVerified (not the forum role Verified):
POST /me/lightning-address/verification(no body). The api pays 1 sat, or the provider'sminSendablewhen higher, capped at 10 sat, with a LUD-12 comment21gifts <32-hex-nonce>. The nonce is never returned to the client.- The user types the code from wallet history into
POST /me/lightning-address/verification/confirm.
Until an invoice payer is injected, start returns 503
{ "error": "Verification payments are not configured" }. The process still
boots. Live verification payments do not work today. Edit or unlink clears
any pending verification (SPEC.md).
Receiver name is stored on the account (POST /me/name). Optional free-text
location is stored on the account (POST /me/location); empty or whitespace
after trim stores null. Location is public on member and view cards. It is
not a setup step, not a posting requirement, not a profile forum note, and
not Nostr kind:0. About me is PUT /me/about (Bearer { text, photo? }): a
non-blank name is required (409 otherwise); Lightning Address is not; empty
text clears the bio (aboutMe null; a live note row is kept). Optional
photo uses the same JPEG/PNG/WebP decode as a forum post (omitted keeps,
null clears, object sets). When no live note exists, empty text without a
new photo does not create or notify; a photo-only or non-empty write against
a missing or hidden note creates a live note without LN and notifies after
the write. Auto name-copy is not a bio (aboutMe is null); a photo still
sets aboutMeHasPhoto. POST /me/name still no-ops the note without LN;
POST /me/lightning-address still creates the name-copy note. Rename does
not create a second note. Other members read live identity plus aboutMe
and aboutMeHasPhoto via GET /members/:accountId (Bearer; rules required).
Kind:0 picture stays the brand icon. Do not invent POST /me/profile.
The owner can copy a view-key link from viewKey on GET /me. The URL is
GET /view/:viewKey. Opening that URL shows a read-only public profile card.
It cannot write and cannot mint a session. Do not invent extra paths.
Guest / one-off giving: the donor clicks Donate on a receiver and pays through browser LNURL-pay (resolve the Lightning Address → invoice → wallet pays). The api is not in the payment path. This works without an account (CONCEPT Donations).
Public GET /lightning-address now resolves and caches LUD-16 metadata
(callback, min/max sendable, optional commentAllowed). There is still no
Donate button; for this guest path the api still does not fetch or pay the
gift invoice (spend-worker invoice fetch is §4 / POST /invoices).
There is no campaign feed and no Donate button in the app today. Do not invent
/feed or /campaigns paths.
HTTP cited: /lightning-address, /gifts/stats, /gifts?day= (see SPEC.md).
Public gift totals are Shipped as GET /gifts/stats (sats, BTC,
historical USD at each gift's UTC-day Coinbase BTC-USD close, and
CHF/EUR/PHP = USD × that UTC day's ECB rate, last business day if closed;
UTC spend-over-time, per person, per month). Individual gifts for one UTC day
are Shipped as GET /gifts?day=YYYY-MM-DD. No invoices. The app
statistics page and /stats/{day} consume those routes.
Optional NIP-57 Zap receipts stay deferred (CONCEPT Out).
Prerequisite: paying is out of this process. The external spend worker holds lightning.space LNDHub credentials and calls:
POST /invoices— this api fetches the BOLT11 from the recipient via LNURL-pay- LNDHub
payinvoice(spend, not this api) POST /invoices/proof— preimage (sha256= payment hash); the api records the gift forGET /gifts/statsandGET /gifts?day=. After recording the gift, when the invoice hasmessageIdthe api inserts a platform-account gift-reply under a top-level post first, thenaddSats. This path does not notify (no in-app rows, no Web Push). WhenmessageIdis already a reply, it hides a deterministic spend marker andaddSatss that reply (no nested gift-reply). When the invoice hasgroupMessageId, the api also inserts a platform-account conversation message in the closed Moderators group (text + paid sats, name21.gifts) after the triggering group message, at payment time; a missing or mismatched group reference is ignored and does not block the 200. Recorded description is21gifts moderatorwhengroupMessageIdis stored, else21gifts daily.
Recurring USD gifts are paid by the external spend worker when the
recipient posts a top-level note with a photo or video, not on a daily timer, and only when
that recipient is funding-eligible today. POST /invoices with messageId
requires that photo or video, and the About-me note counts when it has that
media (else 403 Forum post required); omitted
messageId (moderator stipend) stays any live top-level non-profile post.
GET /invoices/posted returns additive hasMedia (hasPosted unchanged).
POST /invoices 403s
Funding grant required when not eligible. GET /invoices/eligible?address=
is the spend lookup and returns 200 { eligible, status } (status is
effectiveStatus; unknown/basis are { eligible: false, status: "none" }).
POST /invoices/proof does not check the grant.
Recurring donor UI is still a sketch. Do not invent /me/donor,
/me/recurring, or scheduler paths. HTTP that exists today is the
spend-worker invoice surface in SPEC.md.
Public comment / encouragement is a v1 surface. The composer POSTs
{ text } and/or { photo: { contentType, data } } to POST /messages
(requires rules + name + username + Lightning Address — missing requirements are
409 missing_requirements);
a new top-level persist pings spend (POST {SPEND_URL}/ping with
{ address, messageId } and Bearer SPEND_API_TOKEN) only when the author
is funding-eligible today and the new row has media (hasPhoto /
hasVideo / photoCount > 0); otherwise log spend.ping.skipped /
not_eligible or no_media and still 200; when role === 'verified' and any live top-level photo or video exists, including About me, the api also pings { address, messageId, kind: "welcome" }
for the newest live top-level photo or video, including an About-me note,
independent of eligibleToday (the new row itself need not have media;
Spend pays once per Lightning Address; this API may ping again; becoming
verified and saving About me while verified also welcome-ping; boot and a
15-minute timer welcome-ping every verified account that already has a photo or video post;
replies and any role other than verified do not welcome-ping); replies and media replay do
not ping; unset/blank env skips the ping and still returns 200;
the public thread is listed via GET /messages (requires rules; newest first, optional hashtag query (name without #; token filter on text), name
snapshotted at post, sats, payable, hasPhoto, and live author role
— never photo bytes). Bytes are public GET /messages/:id/photo (Nostr imeta). Staff hide is a public-API filter and a best-effort NIP-09 (kind: 5, signed with the note author's custodial nsec) on the durability relay plus the public relay list, plus a best-effort Cloudflare purge of public photo/video URLs. Unsigned/public GET of a hidden row stays 404. A founder/moderator session may GET the hidden row (and photo/video) so the app can show who hid it and when. Hiding a note also retracts in-app notifications whose parent or reply is that note or a direct child. GET /notifications drops remaining rows whose parent or reply message is missing or hidden. Operator GET /debug/messages (Bearer DEBUG_TOKEN) still lists and fetches soft-hidden forum rows and their photo bytes. Restore does not undelete Nostr. The shipped UI
is a messenger-group thread: oldest notes at the top, newest at the bottom,
composer under the newest note. The welcome-forum living-room laws hint is
dismissed via POST /me/forum-laws-dismissed. Posts are standalone kind:1
notes (Damus-visible #bitcoin / #21gifts in content on first sign, plus #<locationHashtagName> and a t tag when account location is non-null (not on the profile note, not kind:0); forum text unchanged; pending notes EVENT before any hashtag/photo re-sign so the sign lease cannot starve fan-out);
the worker fans out when NOSTR_PUBLISH=1. Pay-on-note is
POST /messages/:id/invoice (optional text becomes the zap comment). After a
validated kind:9735 is indexed, a payer gift-reply is inserted only when the
paid row is top-level (parentId null) and is not the official platform
profile note. A zap on a signed reply credits that
reply and does not nest a gift-reply. Gift-only (empty text) replies are not published to Nostr. Unpaid
posts and replies from basis (including the parent author) are 403 until
the author pays 1 sat to 21.gifts (GET /messages/compose-target then
POST /messages/:id/invoice on the platform profile note). verified stays
unpaid-write exempt. A member/invoice zap on that platform note with a comment becomes the
payer’s top-level post (sats 0). An external zap on that same note still
inserts a gift-reply under it. The worker always queries that profile
note’s event id, even after the note ages out of listLatest. A comment inReplyTo:<uuid>\n<body> becomes
a reply on that live top-level parent; a missing, hidden, or nested parent
falls back to a top-level post with the remaining body. An empty comment does
not create a blank living-room post. Extra gifts on someone else’s note still
pay that author. Do not invent /events or /comments paths.
External zap → gift reply. A kind:9735 first credits the addressed member
note under the existing receipt and payment-hash checks. If its embedded
kind:9734 belongs to no account, the api additionally requires the exact
BOLT11 description hash, matching e tag, matching optional msat amount,
and a valid signature. At least 1 sat records permanent zapper entitlement.
Later zaps from the same pubkey skip the repeated entitlement write through a
per-store process-local lowercase-pubkey memo while keeping the rest of the flow.
Attribution happens before the block check; a blocked payer is then durably
dequeued while retaining the request id. Sats stay credited, nothing is shown,
a later unblock does not resurrect zaps made while blocked. An unblocked,
unreplayed request on a live top-level note then becomes a visible gift-reply
with the external profile-name snapshot, comment, sats, accountId: null,
via: "nostr", and no platform Nostr publication. Immediately before creating
that row, after both the account list and relay profile-name lookups resolve,
the payer pubkey is checked again. A block added during either lookup still
wins: the payer is cleared, sats remain credited, and no row is created even
though the post-attribution check passed. Zaps on replies or hidden notes keep
the sats and entitlement but create no row. Replayed requests and receipts
below the external minimum are remembered process-locally per message store,
capped at 10,000 receipt ids by dropping the oldest, so later ticks do not
re-verify their embedded kind:9734 or repeat writes.
On Postgres boot, historical indexed receipts that still lack any payer,
request, or gift-reply attribution are scanned newest-first in 200-row pages.
The scan continues through older receipts with a strict keyset cursor that
advances past every returned row using the last row's immutable ingest
createdAt and receipt eventId, whether or not the row becomes attributed;
changing membership cannot cause offset-style skips or repeats. It stops after
a short page or a hard 10,000-row ceiling. Equal ingest timestamps are ordered
by receipt event id descending in both message-store implementations. It is
idempotent, and its failure is logged without stopping the rest of boot.
External reply → visible after first zap. The inbound reply REQ deliberately
has no since. Before entitlement, a kind:1 reply from an unknown pubkey is
silently skipped. Membership, or zapper entitlement plus not-blocked status, is
decided before the event signature check and before event-specific store reads.
On the first ingest pass after that pubkey's first verified zap, the same older
event is queried again. A per-store in-flight event-id guard prevents overlapping
ticks from storing it concurrently. Only signed kind:0 profiles up to 64 KiB
can supply its name. Control characters, explicit bidirectional controls, names
without a letter or digit, names mixing more than one of the Latin, Cyrillic
and Greek scripts, names containing a default-ignorable code point (checked on
both the name and its NFKD form), except the ZWNJ/ZWJ joiners and the emoji
variation selectors, which stay allowed, and member or reserved-identity
confusables (by glyph, or by sound for Cyrillic) fall back to the truncated
pubkey. Member-name fold
comparisons require every candidate letter to be ASCII or mapped by the
respective table, so unmapped non-Latin letters do not spuriously collide;
eligible pure non-Latin names remain visible. Name resolution precedes a fresh
single-pubkey block check inside the in-flight guard. A block added
during profile lookup wins before limiter acquisition, consumes no budget, and
the event id is still released.
Per-pubkey and global budget is consumed immediately before the row write and
released if that write fails. It is listed and counted like a member reply, but
public JSON exposes only via: "nostr", never the pubkey. This same
recorded-zapper check also gates every read of an existing row (list, count,
and single-fetch), not just the ingest-time decision to persist a new one —
so a reply row that predates this pubkey's zapper entitlement, or a row from a
pubkey that has never zapped, stays invisible until that check passes, and
GET /messages/hidden (staff moderation) is exempt from this read gate. Only
the parent's member author is notified, and only for a numeric event time no
more than one hour old and no more than ten minutes in the future. Missing/non-numeric and
farther-future event times still persist with the existing ingest-time storage
fallback/clamp but do not notify.
Staff hide → block. Moderator DELETE /messages/:id keeps the
normal target-and-direct-reply soft-hide. When the target itself is external,
it also writes the pubkey kill switch and soft-hides that pubkey's other live
rows. Future external gift-replies and inbound replies are skipped while sats
and zapper entitlement remain. Operator restore removes the block only when
restoring its source message; rows hidden elsewhere by the author-wide cascade
are restored individually. Operator zapper and block lists use timestamp
descending, then pubkey descending, in both memory and Postgres.
Private messaging ships as one PN channel: GET/POST /conversations plus
member→platform via POST /contact. Inbox threads have per-viewer unread
via GET /conversations (per-row unread / unreadMessageCount; envelope
unreadCount is the number of unread threads) and
POST /conversations/:id/read. NIP-17 gift wraps and legacy kind:4
inbound; outbound wraps with the sender nsec (platform nsec for staff on
official threads). Forum replies stay on /messages and are not mixed
with PNs. Conversation POST may include { photo } / { photos }
(JPEG/PNG/WebP, ≤10, every kind; empty text is allowed only when at least one photo is present). Photo-bearing rows skip Nostr; text-only Direct/Contact/Damus stay pending. Bytes via authenticated GET
/conversations/:id/messages/:messageId/photo (photo 0) and
/conversations/:id/messages/:messageId/photo/:file (extras 1–9). Lightning gifts in a Direct/Contact thread use
POST /conversations/:id/invoice ({ sats, text? }). Payment is confirmed
when a matching zap receipt is ingested: the api appends a conversation
message (text + sats, or empty text with sats only) and does not
credit the counterpart's profile note or insert a forum gift-reply.
GET /conversations/:id?sinceMessageId= long-polls until that predetermined
row exists. Damus threads cannot be invoiced.
Private mailbox so members can write to 21.gifts without a published email.
Signed-in members POST { text } to POST /contact (name snapshot as
forum messages; normalizeForumText plus a required 1–8000 character body —
forum photo-only empty text does not apply). After the platform account
exists, the contact row is persisted first, then the same text is appended
to the member→platform conversation thread (GET /conversations).
Conversation append failure logs conversations.contact_sync.failed and
still 200. No platform account → 503 Platform account is not configured
(no writes). Operators still read
the legacy mailbox via GET /debug/contacts (DEBUG_TOKEN must not read
member PNs). No public list, no email delivery. Do not invent /events.
Transactional Web Push for signed-in members. The app is installable
(Web App Manifest + service worker). After login the profile card has an
icon-only bell: enable asks the OS permission, then POST /me/push-subscriptions.
Disable DELETEs the endpoint. GET /push/vapid-public is Bearer.
On iPhone Safari the site must be on the Home Screen before the OS will deliver pushes; the app shows that hint. Android and desktop Chrome do not need the icon.
The official platform account (isPlatform) never fans out living-room post,
reply, or zap notifications (in-app or Web Push). For other member-authored
living-room events, the api writes one in-app row to every account except the
actor, then filters recipients by each account's notificationLevel, and
enqueues (does not send inline) one Web Push to every remaining bell subscriber
(an account with at least one push_subscription) except the actor. External
replies use the targeted exception below:
- a forum post payload when someone else posts (title is the author display name, or Someone when blank; the body is the note text on one line, cut at 180 code points, or the photo/video sentence when the text is empty;
url: /notifications,tag: forum_post:<postId>) - a reply payload when someone replies (title and body follow the post rules;
url: /notifications,tag: forum_reply:<replyId>). Damus-only parents still fan out; a self-reply skips only the actor. That includes an unpaidPOST /messagesreply and an inbound member reply the worker persisted. - an external reply payload only for the parent note's member author, never a broadcast, and only when numeric
created_atis at most one hour old and no more than ten minutes in the future. Missing/non-numeric, farther-future, and older event times do not notify. Its notification actor and push title stay the generic "Someone", not the reply's own name. The body is the reply text on one line, cut at 180 code points, or the photo/video sentence when the text is empty. The persisted row remains visible in every suppressed-notification case. - a zap payload when a zap receipt is newly indexed (title is the payer name, or Someone when blank; body is
Sent <amount> sats.;url: /notifications,tag: zap:<id>). The note author is notified unless they are the payer. A platform-account payer does not notify anyone.
Missing pushStore still writes in-app rows. If persist or enqueue
fails, the living-room write still succeeds (HTTP 200 on POST /messages;
worker paths log and keep the row).
The in-app Notifications list (GET /notifications, mark-read POSTs) is
separate from /conversations chat. The list omits rows whose parent or
reply forum message is missing or hidden (forum_reply also checks the
child note; zap only the parent note). moderator_appointed stays. Post, reply, and zap pushes open
/notifications. A new inbound private message enqueues Web Push
/messages?c= (bell subscribers only); badge unreadCount is
notification unread plus listed inbox unread.
The worker sends when VAPID is configured. On outbox retry it does not re-send
an endpoint that already succeeded for that outbox row. Open focused tabs skip
a second banner (service worker). Owners set one of three levels via POST /me/notification-level (all default = current behaviour; active = related top-level post sats>0, zaps also when amountSats>0; mentions = staff/platform actor or reply/zap on the recipient's own note). Filter applies to in-app writes, Web Push, and GET /notifications (list + unreadCount).
HTTP cited: /push/vapid-public, /me/push-subscriptions, /me/notification-level, /debug/push-ping,
/notifications, /notifications/read-all, /notifications/:id/read.
Explicitly not journeys in this file:
- Moderator actions
- NIP-05 badge
- Passkey / non-custodial key material
- Categories and search