Lightning Gifts API

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.

Authentication

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 callWhy
GET /api/remindersgifts due a reminder (before expiry, after a claim)
GET /api/gifts?expired=true&reclaimable=truegifts that expired without being withdrawn
POST /api/gifts/{id}/reclaimreclaim one of those expired gifts
GET /api/users?refundable=true&hasLightningAddress=truesenders holding a reclaimed balance who have a Lightning address
POST /api/users/{id}/refundToLightningAddresspay 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.

Gift statuses

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.

StatusMeansWho acts next
UNFUNDEDcreated, funding invoice unpaidsender pays it (or deletes the gift)
UNSEENfunded, recipient has not opened itrecipient
SEENrecipient opened itrecipient claims it
CLAIMEDclaimed; sats are in the recipient's staging walletrecipient withdraws
WITHDRAWAL_FAILEDa payout was attempted and did not settle; the sats never left the staging walleteither — recipient retries, or sender reclaims
WITHDRAWNpaid out; terminal—
RECLAIMEDsender took it back; sats are in the sender's staging walletsender withdraws
REFUNDEDrefunded to the sender's Lightning address; terminal—
DELETEDdeleted 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.

Endpoint reference

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.

RouteMethodsNotes
/api/giftsPOST, GETPOST 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, DELETEGET/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}/reclaimPOSTmove a refundable gift's sats to the sender's staging wallet; accepts a session, a user key or the operator key.
/api/gifts/{id}/markCopiedPOSTprinted-card bookkeeping.
/api/gifts/{id}/generatePassphrasePOSTreplace a gift's claim passphrase.

Recipient — the public routes take a session where one exists; the passphrase lookup deliberately does not.

RouteMethodsNotes
/api/received-giftsGETthe 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}GETone public gift.
/api/received-gifts/{id}/claimPOSTclaim 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/claimTokensPOSTpassphrase 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}/claimTokenPOSTgiver-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}/markSeenPOSTUNSEEN → SEEN; the public gift page calls this on open.
/api/received-gifts/{id}/markWithdrawnPOSTrecords 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}/withdrawProofPOSTanonymous-flow withdraw-proof exchange: {ct} claim-token in, {withdrawProof} out - single-use, 30 minutes.
/api/csp-reportPOSTCSP Report-Only violation sink for the soak (answers 204; damped).

Withdrawals — where sats actually leave.

RouteMethodsNotes
/api/invoicesPOSTthe 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}GETpayment 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/withdrawalsGETthe caller's withdrawal history.
/api/withdrawalLinksPOSTcreate 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}GETread a link.
/api/withdrawalLinks/{id}/payGETthe 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.

RouteMethodsNotes
/api/users/{id}GET, PUTprofile read/write (?publicProfile=true, ?forceAnonymous=true for the public shape).
/api/usersGEToperator key only; ?refundable=true&hasLightningAddress=true is the scheduler's refund query.
/api/users/{id}/apiKeys, /apiKeys/{key}GET, POST, DELETEmanage the caller's own developer keys.
/api/users/{id}/notifications, .../notifications/markRead, .../notifications/{notificationId}/markReadGET, POSTin-app notifications.
/api/users/{id}/rolesGETthe caller's roles.
/api/users/{id}/journey, /transitionPUT, POSTonboarding progress.
/api/users/{id}/linkedAccountsDELETEunlink a login method.
/api/users/{id}/refundToLightningAddressPOSToperator key; pays a user's reclaimed balance to their saved Lightning address.
RouteMethodsNotes
/api/admin, /api/admin/users, /api/admin/gifts, /api/admin/withdrawals, /api/admin/errors/withdrawalsGEToperator 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/statsGETaggregates 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}/changeStatusPOSToperator status override.
/api/admin/sms4sats/fundPOSTtop up the SMS relay.

Public and internal — no credential needed for the first three; the last one is deliberately dead.

RouteMethodsNotes
/api/statsGET{"totalSatsSent": <int>} — the sum of withdrawn gifts, in sats.
/api/payments-capabilityGET{"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/ratesGETfiat rates for the gift form.
/api/avatar/proxyGEThttps-only image proxy (?url=), used for Nostr avatars.
/api/auth/*varioussign-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/invoicesPOST410, 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.

Worked examples

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"

Rules that come from holding real money

•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.

Vocabulary, and the gift-speaking aliases

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 aliasInternal 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