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-15.
| 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.
Login is passkey-only. LNURL-auth has been removed.
The signed-in view currently lives on /login — there is no separate
/profile route yet. It shows a name form, a Lightning Address form, and
Sign out. Name and Lightning Address are each skippable via
POST /me/setup/skip; living-room rules stay required.
After name/skip and address/skip, the app records living-room rules agreement
via POST /me/rules-agreement. GET /me carries setup (wizard; skip counts
as done), missing (facts; skip does not), 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,
/me, /me/setup/skip, /me/name, /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 sosetupreturns tolightning-addresswhen a name is set or name-skipped)
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 }): 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). When no live
note exists, empty text does not create or notify; a non-empty write against
a missing or hidden note creates a live note without LN and notifies after
the bio write. Auto name-copy is not a bio (aboutMe is null).
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 via GET /members/:accountId
(Bearer; rules required). Photo beyond that note stays custodial kind:0
metadata signed server-side (about is the profile-note text when present,
else 21.gifts). 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 that post first, thenaddSats(the daily gift is visible in the thread, not a silent wallet credit)
Recurring USD gifts are paid by the external spend worker when the
recipient posts a top-level note, not on a daily timer. Invoice HTTP
(POST /invoices / POST /invoices/proof) is unchanged. Recurring donor UI
is still a sketch. Do not invent /me/donor, /me/recurring, or
scheduler paths. HTTP that exists today is only the spend-worker invoice
pair above (SPEC.md).
Public comment / encouragement is a v1 surface. The composer POSTs
{ text } and/or { photo: { contentType, data } } to POST /messages
(requires rules + name + 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); 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, 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 only; operator GET /debug/messages (Bearer DEBUG_TOKEN) still lists and fetches soft-hidden forum rows and their photo bytes. 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, the same payment appears as a forum reply from
the payer. Gift-only (empty text) replies are not published to Nostr. Unpaid
replies from basis/verified (not the parent author) are 403. Do not invent /events or /comments paths.
Private messaging ships as one PN channel: GET/POST /conversations plus
member→platform via POST /contact. 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.
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–500 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 api writes one in-app row to every account except the actor, and
enqueues (does not send inline) one Web Push to every bell subscriber
(an account with at least one push_subscription) except the actor:
- a forum post payload when someone else posts (
titleNew post on 21.gifts,url: /notifications,tag: forum_post:<postId>) - a reply payload when someone replies (
titleNew reply on 21.gifts,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. - a zap payload when a zap receipt is newly indexed (
titleBitcoin on 21.gifts,bodySomeone sent sats.,url: /notifications,tag: zap:<id>). The note author is notified unless they are the payer.
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. Post, reply, and zap pushes open
/notifications.
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). Do not invent preference HTTP in v1.
HTTP cited: /push/vapid-public, /me/push-subscriptions, /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