Screen-by-screen sketch of the five journeys named in CONCEPT next-step 7, plus the shipped in-app contact mailbox (journey 6) and 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-08-30.
| 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). The first persisted
non-empty name also creates exactly one top-level profile forum note; rename
does not create a second note or change its text. Other members read live
identity plus that note via GET /members/:accountId (Bearer; rules required).
Photo and story beyond that note stay 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, and
historical USD at each gift's UTC-day Coinbase BTC-USD close; 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=
Recurring daily gifts as fixed USD amounts and the donor UI are 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 is not required to post — missing
requirements are 409 missing_requirements);
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). 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; 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. 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 enqueues (does not send inline):
- a forum payload when someone else posts (
tag: forum) - a zap payload when a zap receipt is newly indexed onto the author's note
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.
Explicitly not journeys in this file:
- Moderator actions
- NIP-05 badge
- Passkey / non-custodial key material
- Categories and search