The app's own HTTP API: the routes the interface calls, the two credentials that open them, and the rules an integrator has to respect when real sats are moving.
Everything here is served by the same Next.js service that renders the site, under ${APP_URL}/api. Responses are JSON unless stated otherwise; an endpoint that fails answers with the matching HTTP status and, where the route has something to say, a plain-text body.
There is no versioned public API and no SDK. These are the routes in app/pages/api/, documented because the scheduler and the CLI probes depend on them — treat a route's name and shape as fixed, and read the source for anything not covered here.
Three credentials reach these routes. Pick by who you are, not by convenience — the third one is an operator secret and grants everything.
•Session cookie — how the web UI authenticates. Sign in at /signin; the magic link is emailed (captured by Mailhog in development). The cookies are __Host-/__Secure- and Secure-only, so a browser on a plain-HTTP origin can never hold a session: drive the https origin.
•Per-user API key — Authorization: Bearer <key>. Created in Profile → Developer API Keys, or POST /api/users/{id}/apiKeys. The key is the row id in UserAPIKey (a base64 uuid) and it resolves to that user with no scopes and no expiry — it is a password, so keep it in a secret store and delete the row to revoke it. GET /api/users/{id}/apiKeys lists a user's keys, DELETE /api/users/{id}/apiKeys/{key} removes one.
•Operator key — Authorization: Bearer *** A single instance-wide secret from the app's own API_KEY environment variable. The scheduler uses it; nothing else should. It travels in a HEADER, never in a query string — the ?apiKey=` query form is no longer accepted (F-14 closed 2026-10-28: a query string is written verbatim into the reverse proxy's access log). Five routes accept it and no user session is required:
| Scheduler call | Why |
|---|---|
GET /api/reminders | gifts due a reminder (before expiry, after a claim) |
GET /api/gifts?expired=true&reclaimable=true | gifts that expired without being withdrawn |
POST /api/gifts/{id}/reclaim | reclaim one of those expired gifts |
GET /api/users?refundable=true&hasLightningAddress=true | senders holding a reclaimed balance who have a Lightning address |
POST /api/users/{id}/refundToLightningAddress | pay that balance out to their address |
A 401 means no credential was accepted; a 403 means the credential was fine but the caller is someone else (a user key may only act as its own user, and the admin routes require a SUPERADMIN UserRole row).
The scheduler's own binary still calls the internal spelling (/api/tipper/tips, /api/users, /api/reminders); the aliases accept the same parameters, so either path works. Only the operator-key routes below are operator-gated.
Tip.status is the state machine everything else is built on. Two of these are money states for the recipient, and one of them is the reason a sender's dashboard groups the way it does.
| Status | Means | Who acts next |
|---|---|---|
UNFUNDED | created, funding invoice unpaid | sender pays it (or deletes the gift) |
UNSEEN | funded, recipient has not opened it | recipient |
SEEN | recipient opened it | recipient claims it |
CLAIMED | claimed; sats are in the recipient's staging wallet | recipient withdraws |
WITHDRAWAL_FAILED | a payout was attempted and did not settle; the sats never left the staging wallet | either — recipient retries, or sender reclaims |
WITHDRAWN | paid out; terminal | — |
RECLAIMED | sender took it back; sats are in the sender's staging wallet | sender withdraws |
REFUNDED | refunded to the sender's Lightning address; terminal | — |
DELETED | deleted by the sender while unfunded | — |
UNFUNDED and UNSEEN are the only statuses a gift is created in. Expiry is not a status: it is the expiry timestamp on the gift, and only gifts in UNFUNDED, UNSEEN, SEEN or CLAIMED are eligible for the scheduler's reclaim.
Route names below are the public aliases. Every one of them re-exports the internal tip* route of the same shape (the pairing is in Vocabulary, and the payload fields keep their names). Only the paths changed; the behaviour is byte-identical. The exceptions: /api/claimTokens and /api/csp-report exist only under their public name — there is no internal twin to prefer.
Giver — the sender: any route below takes a session, or a user key acting as that user.
| Route | Methods | Notes |
|---|---|---|
/api/gifts | POST, GET | POST creates a gift (CreateTipRequest: amount, currency, expiry, onboardingFlow, tippeeLocale, anonymousTipper, generatePassphrase, passphraseLength — word count 4–6 when generatePassphrase is true). `amount` is always the gift value in sats (a positive whole number) whatever currency says — currency is display metadata and the API never converts; the web form's inputMethod field is client-side UI state the API ignores. GET lists the caller's gifts; with the operator key, ?expired=true&reclaimable=true returns the reclaimable set instead. |
/api/gifts/{id} | GET, PUT, DELETE | GET/PUT return or update pending gift details. DELETE removes an unfunded gift — it refuses (409) and flips the gift to UNSEEN instead if the funding invoice turns out to be paid. |
/api/gifts/{id}/reclaim | POST | move a refundable gift's sats to the sender's staging wallet; accepts a session, a user key or the operator key. |
/api/gifts/{id}/markCopied | POST | printed-card bookkeeping. |
/api/gifts/{id}/generatePassphrase | POST | replace a gift's claim passphrase. |
Recipient — the public routes take a session where one exists; the passphrase lookup deliberately does not.
| Route | Methods | Notes |
|---|---|---|
/api/received-gifts | GET | the caller's received gifts — populated only once a gift is CLAIMED (the recipient account is linked at claim); before claiming, read GET /api/received-gifts/{id} (public shape). ?publicTip=true returns the public shape; ?passphrase=... looks one up by its claim passphrase and is rate-limited per IP. |
/api/received-gifts/{id} | GET | one public gift. |
/api/received-gifts/{id}/claim | POST | claim a SEEN gift 409 if already claimed, sender, expired — and requires the one-time proof {ct} from the minted claim URL; a bare gift-id URL answers 410 and claims nothing. |
/api/claimTokens | POST | passphrase prove-and-mint (the redeem flow): {passphrase} in, {claimUrl} out — single-use, 30 minutes; a wrong passphrase answers the same 404 as an unknown one; 5/min/IP. |
/api/gifts/{id}/claimToken | POST | giver-side mint for share surfaces (copy, print QR, reminders): {claimUrl} out — single-use, ≤7 days and ≤ gift expiry (30 days for anonymous gifts, whose holder has no other handle; the print page sends {printed: true} and gets the GIFT's own expiry instead — a printed card may be handed over weeks later); giver session or operator key. |
/api/received-gifts/{id}/markSeen | POST | UNSEEN → SEEN; the public gift page calls this on open. |
/api/received-gifts/{id}/markWithdrawn | POST | records a non-custodial (NWC) withdrawal: session = the gift's recipient; anonymous (SKIP) = requires the one-time {ct} withdraw proof from POST /api/received-gifts/{id}/withdrawProof (a bare id is refused with guidance back to the original link). |
/api/received-gifts/{id}/withdrawProof | POST | anonymous-flow withdraw-proof exchange: {ct} claim-token in, {withdrawProof} out - single-use, 30 minutes. |
/api/csp-report | POST | CSP Report-Only violation sink for the soak (answers 204; damped). |
Withdrawals — where sats actually leave.
| Route | Methods | Notes |
|---|---|---|
/api/invoices | POST | the payout. Body {flow, invoice, tipId?}; flow is tippee, tipper or anonymous. The anonymous flow requires tipId and the one-time {ct} withdraw proof. Answers 204 on success. ?isWebln=true records the webln method. |
/api/invoices/{id} | GET | payment status of a gift's funding invoice. Session-gated: the gift's giver, or its recipient once claimed, or the operator key (the integration path for anonymous-gift holders — they never get a session). Was open to anyone before the 2026-10-28 audit batch; 30/min/IP. |
/api/withdrawals | GET | the caller's withdrawal history. |
/api/withdrawalLinks | POST | create an LNURL-withdraw link for a gift. Anonymous-flow creation needs real authority — a one-time withdraw proof (ct, recorded as proofBound on the row) or the gift's own giver session (the share card's LNURL-w QR). Links minted without proofBound are refused at pay. |
/api/withdrawalLinks/{id} | GET | read a link. |
/api/withdrawalLinks/{id}/pay | GET | the LNURL-withdraw callback — what a wallet calls. Anonymous links minted without authority (proofBound=false) answer ERROR; regenerate from the original gift link. A cross-origin web page with an Origin header gets the same opaque {"status":"OK"} a used link gives; wallets (no Origin) and wallet browser extensions reach the real gates. |
Account — self-service, plus the two operator-only families.
| Route | Methods | Notes |
|---|---|---|
/api/users/{id} | GET, PUT | profile read/write (?publicProfile=true, ?forceAnonymous=true for the public shape). |
/api/users | GET | operator key only; ?refundable=true&hasLightningAddress=true is the scheduler's refund query. |
/api/users/{id}/apiKeys, /apiKeys/{key} | GET, POST, DELETE | manage the caller's own developer keys. |
/api/users/{id}/notifications, .../notifications/markRead, .../notifications/{notificationId}/markRead | GET, POST | in-app notifications. |
/api/users/{id}/roles | GET | the caller's roles. |
/api/users/{id}/journey, /transition | PUT, POST | onboarding progress. |
/api/users/{id}/linkedAccounts | DELETE | unlink a login method. |
/api/users/{id}/refundToLightningAddress | POST | operator key; pays a user's reclaimed balance to their saved Lightning address. |
| Route | Methods | Notes |
|---|---|---|
/api/admin, /api/admin/users, /api/admin/gifts, /api/admin/withdrawals, /api/admin/errors/withdrawals | GET | operator dashboards — require a SUPERADMIN session. A non-admin gets 403 with an empty body, which is why the UI renders its own access panel. |
/api/admin/stats | GET | aggregates for the admin stats table: user count plus per-bucket gift counts and sats sums (gifts exclude DELETED; claimed-not-withdrawn is CLAIMED + WITHDRAWAL_FAILED). Requires a SUPERADMIN session. |
/api/admin/gifts/{id}/changeStatus | POST | operator status override. |
/api/admin/sms4sats/fund | POST | top up the SMS relay. |
Public and internal — no credential needed for the first three; the last one is deliberately dead.
| Route | Methods | Notes |
|---|---|---|
/api/stats | GET | {"totalSatsSent": <int>} — the sum of withdrawn gifts, in sats. |
/api/payments-capability | GET | {"canReceive": <bool>, "reason": <string>, "source": <string>, "checkedAt": <iso>} — whether this deployment can receive a Lightning payment at all right now. The funding surfaces render a "payments are paused" notice from it, and the operator's external prober polls the same route from outside the box, so there is one answer rather than two. It fails open: canReceive: true also covers "no probe is configured", and that is the only value it can take until a node probe is wired. Cache-Control: no-store. Check it before creating a gift somebody is about to fund — an invoice minted while it answers false cannot be paid, and creating the gift still succeeds, so nothing will stop you. |
/api/exchange/rates | GET | fiat rates for the gift form. |
/api/avatar/proxy | GET | https-only image proxy (?url=), used for Nostr avatars. |
/api/auth/* | various | sign-in: email/two-factor link, LNURL-auth, and Nostr (NIP-07 or an NWC paste) — POST /api/auth/nostr/challenge issues the signing challenge. |
/api/webhooks/invoices | POST | 410, by design — upstream's invoice webhook was replaced by the funding poll (lib/pollFundingStatus.ts), which reads a gift's own invoice and flips UNFUNDED → UNSEEN. |
Create a gift as the signed-in user (session cookie), then read your own list with a developer key.
# amount is in SATS (21000 sats below); currency is display metadata only
curl -sS -X POST "$APP_URL/api/gifts" \
-H 'Content-Type: application/json' \
-b "$SESSION_COOKIE" \
-d '{
"amount": 21000,
"currency": "USD",
"expiry": "2026-10-08T00:00:00.000Z",
"onboardingFlow": "SKIP",
"tippeeLocale": "en",
"anonymousTipper": false,
"generatePassphrase": false,
"passphraseLength": 4
}'
curl -sS "$APP_URL/api/gifts" -H "Authorization: Bearer $DEVELOPER_KEY"Withdraw as the recipient. The rule that surprises integrators: the payout invoice must equal the exact sum of every withdrawable gift on the wallet — the app sweeps; it does not pay a partial amount, and a mismatch answers 500 "Withdrawal request does not match user/tip balance".
curl -sS -X POST "$APP_URL/api/invoices" \
-H 'Content-Type: application/json' \
-b "$SESSION_COOKIE" \
-d '{"flow":"tippee","invoice":"<bolt11 for the exact sum>"}'
# 204 No Content on success; the HTTP status is the whole response body.Drive the scheduler's two jobs by hand — the same calls the hourly job makes.
curl -sS -H "Authorization: Bearer $API_KEY" "$APP_URL/api/gifts?expired=true&reclaimable=true" curl -sS -X POST -H "Authorization: Bearer $API_KEY" "$APP_URL/api/gifts/cklq1234/reclaim" curl -sS -H "Authorization: Bearer $API_KEY" "$APP_URL/api/users?refundable=true&hasLightningAddress=true"
•Two kill switches gate the money paths, read per request: CREATE_TIP_ENABLED (creation refused unless exactly "true") and WITHDRAWALS_ENABLED (payouts refused with 500 "Withdrawals are currently disabled"). A frozen instance still answers reads normally.
•Withdrawals are rate-limited per wallet — WITHDRAWAL_RETRY_DELAY (30s; the UI's message says a minute). Two attempts inside the window fail with an explanatory 500, and neither moves sats.
•A pending payout is not a failed payout. LNbits accepts a payment and settles asynchronously; the app waits for a verdict (lib/lnbits/waitForPaidPayment.ts) before calling a failure, and reports a still-unverifiable payment as payment_unconfirmed rather than success.
•Failures are classified — insufficient_liquidity, no_route, invoice_expired, already_paid, self_payment, lnbits_unavailable, payment_failed, payment_unconfirmed, unknown — and the code rides in the error text and in the operator alert. "The node's liquidity is spent" must not look like "LNbits was down".
•Deleting a gift is guarded. DELETE /api/gifts/{id} re-checks the gift's own wallet before removing it, and fails closed. Any integrator that deletes gifts on the strength of a polled UNFUNDED reads the same race the guard exists to close.
•A notification failure never fails a payout. Operator alerts are POSTed after the sats move and their failure is logged, not thrown.
The product says gift, giver and recipient. The code, the database and the app's own client say *tip*, *tipper* and *tippee*: the Prisma model is Tip, the columns are tipperId, tippeeId and tipId. That is deliberate — renaming the data model buys nothing a user can see.
So most routes exist twice (/api/claimTokens and /api/csp-report are the single-spelling exceptions). The alias set below re-exports the internal handlers, so both spellings hit the same code and return byte-identical payloads, field names included (tipId, tipperId and tippeeId are what the JSON says whatever the route is called). Anything a third party reads should use the alias.
| Public alias | Internal route |
|---|---|
/api/gifts | /api/tipper/tips |
/api/gifts/{id} | /api/tipper/tips/{id} |
/api/gifts/{id}/reclaim | /api/tipper/tips/{id}/reclaim |
/api/gifts/{id}/markCopied | /api/tipper/tips/{id}/markCopied |
/api/gifts/{id}/generatePassphrase | /api/tipper/tips/{id}/generatePassphrase |
/api/gifts/{id}/claimToken | /api/tipper/tips/{id}/claimToken |
/api/received-gifts | /api/tippee/tips |
/api/received-gifts/{id} | /api/tippee/tips/{id} |
/api/received-gifts/{id}/claim | /api/tippee/tips/{id}/claim |
/api/received-gifts/{id}/markSeen | /api/tippee/tips/{id}/markSeen |
/api/received-gifts/{id}/markWithdrawn | /api/tippee/tips/{id}/markWithdrawn |
/api/received-gifts/{id}/withdrawProof | /api/tippee/tips/{id}/withdrawProof |
/api/anonymous-gifts | /api/anonymous/tips |
/api/admin/gifts | /api/admin/tips |
/api/admin/gifts/{id} | /api/admin/tips/{id} |
/api/admin/gifts/{id}/changeStatus | /api/admin/tips/{id}/changeStatus |