PRISM-INSIGHT
13개의 특화 AI 에이전트가 한국·미국 주식을 실시간 분석합니다. 오픈소스, 무료
저장소 정보는 아직 수집하지 않았습니다.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
메이커가 쓴 소개는 위의 한 줄이 전부입니다. 더 자세한 내용은 저장소 README에서 읽을 수 있습니다. 저장소 README ↗
README 전체를 불러오는 중입니다.
README 전문 보기 ↗Bitcoin invoicing for independent professionals. Create an invoice in your local currency, your client pays in bitcoin straight to your own wallet (non-custodial: SatSend never holds funds), and SatSend detects the payment and tells you both. Zero transaction fees, nothing to set up.
The plain-English version. Skip to "Detailed mechanics" further down if you want code references and timing tables.
When a payer sends Bitcoin to an invoice, the invoice's status needs to change from Pending → Payment Detected → Paid. That status lives in our database. Two pages care about it:
/invoice/[id] — public, anyone with the link can see it./invoices — private, only the logged-in invoice owner.Both pages need to update as soon as the status changes, no matter who triggered it.
| Term | What it means here |
|---|---|
| Mempool | The "waiting room" for Bitcoin transactions. After a wallet broadcasts a tx but before a miner confirms it, it sits in the mempool. We use mempool.space as our window into it. |
| Confirmation | A miner has put the tx into a block. 0 confirmations = "broadcast, in mempool, not yet in a block". 1+ confirmations = "in a block on the chain". |
| WebSocket | A long-lived connection between your browser and a server that lets the server push messages whenever something happens. No need for the browser to keep asking. We use one to mempool.space and one to Supabase. |
| Polling | The opposite of a WebSocket. The browser (or server) repeatedly asks "anything new?" on a timer. Slower and chattier than a push, but works as a fallback when sockets aren't available. |
| Cron | A scheduled job that runs in the background with no browser involved. An external cron service (cron-job.org) hits ours every minute, with a GitHub Actions workflow as a slower backup. Think of it as a server-side alarm clock that wakes up, checks the mempool for every active invoice, and goes back to sleep. |
| Realtime (Supabase) | A separate WebSocket from the browser to Supabase that delivers database changes as they happen. Anything that updates an invoice row anywhere in the system shows up on the page within ~1 second. |
There are exactly three things that can spot an incoming payment. Faster ones first.
Quick reference — what runs when (the three "payer on the page" states):
State What's running Page open, hasn't clicked "Pay now in Bitcoin" (window-shopper) mempool.space WebSocket only. No REST polling. Page open, clicked "Pay now in Bitcoin" (revealed), hasn't clicked "Mark as Sent" WebSocket + phased active poll: 5 s × 12, 10 s × 6, 15 s × 4, 30 s × 2, 60 s × 1 = 25 polls over 5 minutes then stops. (v1.4.13.4) Page open, clicked "Mark as Payment Sent" WebSocket + the dialog's own front-loaded burst: 2 s × 5, 3 s × 5, 5 s × 3, 10 s × 2 = 15 polls over 60 s.
When a payer opens /invoice/[id], the page immediately opens a WebSocket to mempool.space and asks it to "track this BTC address". The connection stays open for as long as the page is open.
The moment a wallet broadcasts a payment to that address, mempool.space pushes a message down the socket. The page sends "I saw it" to our API, which writes payment_detected to the database.
You do not need to click the "Pay now in Bitcoin" button for this to work. That button only reveals the QR code; the watcher runs in the background regardless. Closing the tab stops the watcher.
There are two clocks here, and it's worth keeping them straight:
/api/cron/payment-sweep every minute, forever, regardless of how many invoices exist. This is what "no browser needed" refers to. A GitHub Actions workflow also calls it as a backup, but GitHub delays scheduled runs badly (in October 2026 its */5 schedule actually ran every 3–6 hours), so it can't be the main clock (v1.5.8). Vercel's own cron is capped at once a day on the Hobby plan.next_check_at). When the system clock fires, the endpoint only checks invoices whose own scheduled time has come up — typically a small handful per tick, not every outstanding invoice.Each invoice's schedule is front-loaded, then tapers off:
[15s, 5min, 10min, 30min], which left a 5-minute gap after the first cron miss.)So: the scheduler fires every ~5 minutes (system-wide), but a given invoice is not checked every 5 minutes — most of the time it's resting between scheduled checks. This avoids hammering mempool.space and burning compute on invoices nobody is paying.
This is what catches payments while everyone's tabs are closed. It's slower than the mempool socket (up to ~5 min for the next scheduled tick + however long mempool.space takes to see the tx + the per-invoice schedule gap) but it's tireless and unattended.
mempool.space's WebSocket sometimes appears connected but silently misses pushing a tx event to us — particularly on testnet. And the WS routinely dies after ~60s of inactivity. To close both gaps, the page fires REST polls in parallel with the WebSocket, on a phased cadence that mirrors the "Mark as Sent" button's front-loaded approach:
| Phase | Interval | Count | Phase duration | Cumulative |
|---|---|---|---|---|
| 1 | 5 s | 12 | 60 s | 12 polls / 60 s |
| 2 | 10 s | 6 | 60 s | 18 polls / 120 s |
| 3 | 15 s | 4 | 60 s | 22 polls / 180 s |
| 4 | 30 s | 2 | 60 s | 24 polls / 240 s |
| 5 | 60 s | 1 | 60 s | 25 polls / 300 s — then stops |
The first minute (the dominant wallet-pay window) keeps the fast 5 s cadence so a typical pay-and-watch flow gets sub-5-second detection. After 5 minutes of unsuccessful polling, the page stops polling entirely and falls back to the WebSocket (if alive) and the cron.
Two preconditions for any of these polls to fire:
A window-shopper viewing a published invoice but never revealing the BTC details causes zero client-side polling load — only payers who've signalled intent to pay generate this traffic.
History: v1.4.13 added a 2 s exp-backoff REST fallback fired only on WS-close. v1.4.13.1 added the always-on (when revealed) 5 s active poll. v1.4.13.2 gated the v1.4.13 fallback to revealed payers only. v1.4.13.3 removed the v1.4.13 fallback entirely — it overlapped with the active poll. v1.4.13.4 swapped the flat 5 s × 60 cadence for the phased 25-poll schedule above.
Spotting the payment is half the job. Once the database row changes, both the payer's and the owner's open pages need to see the change.
That's what Supabase Realtime is for. Both pages open a Realtime WebSocket when they mount. When the database row changes — by any of the three mechanisms above — Realtime pushes the new row down those sockets, and the badge updates within ~1 second.
So: a single status change can take three hops (mempool socket → API → database → Realtime → badge), but most of that is sub-second. The slowest link is whoever spotted the tx, not the push to the screen.
The most common reason is the mempool socket didn't catch it, so you're waiting for the cron tick to find it instead. That happens when:
When this happens, the cron eventually finds it → updates the database → Realtime pushes to the page → badge flips.
Latency targets (v1.4.13):
Just the scheduler, plus a tapering schedule. After publish, checks are due at +15 s, +45 s, +1 min 45 s and so on out to +48 min, then stop if nothing has hit the mempool — at that point we assume the payer has abandoned the invoice. (The actual polls land on the ~5-minute scheduler ticks, not the exact due times.) As soon as a tx does hit the mempool, the schedule speeds back up: 10 min ×3, then 1 h ×6, then 4 h ×12, then 8 h ×24, then stop after ~11 days. A confirmation at any point promotes the invoice straight to Paid.
Each invoice must use a fresh BTC receive address — one with no prior on-chain activity. The assumption is that owners are running an HD wallet (Sparrow, BlueWallet, Ledger, etc.) that auto-derives a new receive address per invoice. Pasting in a reused address, an exchange deposit address, or any address that has already received Bitcoin is unsupported.
Why fresh-only:
Current enforcement: the app rejects publishing an invoice if either of the following holds:
chain_stats.tx_count > 0 or mempool_stats.tx_count > 0 per GET /api/address/<addr> on mempool.space.The on-chain freshness check is conditional on NEXT_PUBLIC_BTC_NETWORK — testnet addresses are validated against testnet4, mainnet addresses against mainnet.
If mempool.space is unreachable: the publish-time check fails open. We log a warning ([publish] mempool.space unreachable, address history check skipped for invoice <id>) and allow the publish to proceed — owners are not blocked on an external dependency. The uniqueness check (item 1) still runs and remains authoritative.
For the implementation specifics — file paths, exact intervals, code references — read on. The plain-English section above is enough to use and debug the system.
| Scenario | Active mechanism | Frequency | Time-to-detect (typical) |
|---|---|---|---|
(A1) Payer on /invoice/[id], has not clicked "Pay now in Bitcoin" | Mempool WebSocket only | Real-time push; no client REST polling | < 1 second (push) |
| (A2) Payer clicked "Pay now in Bitcoin", has not clicked "Mark as Sent" | Mempool WebSocket + phased active alongside-WS poll | Real-time push; phased poll: 5s×12, 10s×6, 15s×4, 30s×2, 60s×1 = 25 polls / 5 min then stops (v1.4.13.4) | < 1 second (push) or ≤ 5 s (alongside poll) |
(B) Payer on /invoice/[id], clicks "Mark as Payment Sent" | Tiered active polling for 60 seconds | 5×2s + 5×3s + 3×5s + 2×10s = 15 polls in 60s | 2–10 seconds |
| (C) Nobody has a page open | Cron sweep (background poll) | Every minute, per-invoice back-off schedule | 1–30 minutes pre-mempool; 10 min – 8 h post-mempool |
(C2) Owner on /invoices | List check (POST /api/invoices/check-payments) | About every 60 s while the tab is visible; up to 10 waiting invoices, payment detected first | ≤ ~1 minute |
(D) Owner on /invoices or /invoices/[id] | Supabase Realtime subscription | Pushed as soon as any other path updates DB | < 1 second after DB update |
(E) Payer on public /invoice/[id] | Supabase Realtime subscription (anon) | Pushed as soon as any other path updates DB | < 1 second after DB update |
(A), (B), (C), (C2) are detection paths — they spot the on-chain payment. (D), (E) are display paths — they push the resulting database change to whatever page is open.
File: src/app/invoice/[id]/payment-watcher.tsx
/api/invoices/[id]/payment-status with status=payment_detected.status=paid. Note (v1.4.19-H): this POST body is only a "check now" hint — the route re-fetches the real transaction from mempool.space itself and decides the actual outcome (see "Payment amount verification" below). It cannot be forged by POSTing a claimed status directly.paymentRevealed=true (payer has clicked "Pay now in Bitcoin", or the invoice is already in a detected/paid state), the watcher also fires REST polls in parallel with the WS, on the v1.4.13.4 phased schedule (5 s × 12, then 10 s × 6, then 15 s × 4, then 30 s × 2, then 60 s × 1 = 25 polls over 5 min). Closes the "WS connected but silently missing pushes" gap. Stops entirely once all phases are exhausted; pauses while the tab is hidden. This is the only client-side polling path. The v1.4.13 exp-backoff fallback was removed in v1.4.13.3 — it was vestigial once the active poll covered the same cases.paid.onStatusChange(status, txid), so the public payer page can render the mempool.space transaction link the moment we report a payment — no manual refresh, no Supabase realtime roundtrip required (v1.4.13).Behaviour matrix for path A (final, v1.4.13.4):
| Page state | WS state | Client-side polling |
|---|---|---|
| Window-shopper (not clicked "Pay now in Bitcoin") | alive | none — WS only |
| Window-shopper | dead | none — cron is the safety net |
| Revealed (clicked "Pay now in Bitcoin"), NOT clicked "Mark as Sent" | alive or dead | Phased active poll: 5 s × 12 → 10 s × 6 → 15 s × 4 → 30 s × 2 → 60 s × 1 = 25 polls over 5 min, then stop. Same cadence regardless of WS state. |
| Clicked "Mark as Sent" | (n/a — dialog handles this) | Dialog's front-loaded 60 s burst (path B). The active poll continues underneath. |
Latency: effectively instant (push). Fallback polling only kicks in if the socket dies.
Files: src/app/invoice/[id]/mark-sent-button.tsx + dialog component.
Opens a dialog that actively polls mempool.space for exactly 60 seconds on a front-loaded schedule:
| Phase | Interval | Count | Running total |
|---|---|---|---|
| 1 | 2 s | 5 | 10 s |
| 2 | 3 s | 5 | 25 s |
| 3 | 5 s | 3 | 40 s |
| 4 | 10 s | 2 | 60 s |
| Total | — | 15 polls | 60 s |
States:
The detected dialog also auto-pops on any pending/overdue → payment_detected/paid transition, even if the payer never clicked the button — so if the cron (path C) or the passive watcher (path A) fires while the dialog is open, the dialog still resolves with confirmation.
Files: src/app/api/cron/payment-sweep/route.ts + src/lib/invoices/payment-schedule.ts + vercel.json.
Files also: src/lib/invoices/check-invoice-payment.ts (the per-invoice check, shared with the /invoices list check so the two cannot drift).
A cron service (cron-job.org) hits /api/cron/payment-sweep every minute in production; .github/workflows/payment-sweep.yml (GitHub Actions, */5 * * * *, in practice hours apart) is a backup. Overlapping runs are safe: each write only applies if the invoice's status is still the one that was read, and only the run whose write lands sends emails.
Locally, plain npm run dev has no background checker (the schedulers only call production, and production's sweep must never touch the test database). Use npm run dev:sweep instead: it starts the dev server with the sweep switched on for that process only (PAYMENT_SWEEP_ENABLED=true and a throwaway CRON_SECRET, gone when you stop it) and calls the sweep every minute. It refuses to start unless NEXT_PUBLIC_SUPABASE_URL is the test project (scripts/dev/sweep-guard.mjs). The endpoint:
CRON_SECRET.next_check_at <= now() AND status IN ('pending', 'payment_detected', 'overdue'), oldest next_check_at first, then loops to drain the due queue (up to 500 per run).GET /api/address/<addr>/txs, then runs the pure decision function decidePaymentSchedule(…) which produces the next state and the next next_check_at..eq('status', prior)).sweepOverdue helper, decision lives in src/lib/invoices/overdue-actions.ts): any pending invoice whose due_date is strictly before today's UTC date is flipped to overdue and the transition is recorded in the activity feed (marked_as_overdue). Independent of mempool — the sweep runs even if the payment-poll loop has errors. Cron response includes an overdueFlips counter.The per-invoice schedule is two-stage:
Pre-mempool (mempool_seen_at IS NULL — nothing broadcast yet). After publish, checks at +15 s, +30 s, +60 s, +2 min, +5 min, +10 min, +30 min. If still nothing by ~48 minutes total, polling stops for that invoice (the passive watcher and the fast-path API still work if the payer returns to the page). The intervals live in PRE_MEMPOOL_DELAYS_MS (src/lib/invoices/payment-schedule.ts); index 0 is the publish → first-check delay, consumed by publishStatePatch in src/app/(dashboard)/invoices/actions.ts so there is a single source of truth. As of v1.4.28 (S3) the schedule is time-based: the next check is the first boundary past now − published_at, so a late or missed tick lands on the right boundary instead of consuming a stage.
| Attempt | Delay from previous | Elapsed since publish |
|---|---|---|
| 1 | + 15 s | 15 s |
| 2 | + 30 s | 45 s |
| 3 | + 60 s | 1 min 45 s |
| 4 | + 2 min | 3 min 45 s |
| 5 | + 5 min | 8 min 45 s |
| 6 | + 10 min | 18 min 45 s |
| 7 | + 30 min | 48 min 45 s |
| — | stop (next_check_at = null) | — |
Note on the tick. The external scheduler fires the
payment-sweeproute about every 5 minutes. The schedule's delay is the earliest moment the invoice is eligible — the actual poll lands at the next scheduled tick after that. So the dense early entries (15 s, 30 s, 60 s) mostly collapse into the first few ticks, and a browserless payment is typically detected within ~5 minutes (depending on when in the window the tx lands). The mempool socket and the fast-path API remain the sub-second paths when a page is open.
Pre-v1.4.13.5 history. The schedule used to be
[15 s, 5 min, 10 min, 30 min]— i.e. only two polls in the first 5 minutes. If mempool.space hadn't indexed the broadcast tx by the t=60 s first-poll window (very common on testnet), the next attempt was 5 minutes out. v1.4.13.5 fills in that gap.
Post-mempool (mempool_seen_at IS NOT NULL — tx broadcast but not confirmed). Cadence spreads the checks over ~11 days:
| Stage | Interval | Count | Stage duration | Cumulative polls |
|---|---|---|---|---|
| 1 | 10 min | 3 | 30 min | 3 |
| 2 | 1 h | 6 | 6 h | 9 |
| 3 | 4 h | 12 | 48 h | 21 |
| 4 | 8 h | 24 | ~8 days | 45 |
| — | stop | — | — | — |
Total post-mempool window before giving up: ~11 days. A confirmed tx that clears the amount/depth checks below (see "Payment amount verification") promotes the invoice to paid (or underpaid) and next_check_at = null.
The fast-path route /api/invoices/[id]/payment-status (triggered by path A) delegates to the same decidePaymentSchedule helper, so whichever path fires first writes the same row shape — the two systems stay in lock-step.
Both detection paths above (the fast-path route and the cron) run every payment through the same two gates before finalizing a paid/underpaid verdict — neither path trusts a claimed status on its own:
tip − block_height + 1; a tx sitting at depth 1 is treated the same as unconfirmed (still payment_detected, no verdict yet).total_fiat with a 5% tolerance band:
< 95% → underpaid (a new invoice status, mirrors paid/overdue; the owner can manually override via the Mark As menu)95–105% → paid> 105% → paid, plus an overpaid: true flag surfaced next to the badgeIf the BTC price oracle is unavailable at the moment a tx confirms, the verdict is deferred (invoice stays payment_detected) rather than guessed — the next poll tries again.
The fast-path route additionally requires the invoice's access-code cookie (if one is set) before processing a POST at all, closing off callers who know an invoice ID but not its access code.
File: src/app/(dashboard)/invoices/use-invoice-realtime.ts
invoices table for rows owned by the current user.0006_invoices_replica_identity_full.sql sets REPLICA IDENTITY FULL so UPDATEs carry every column — essential for reliable Realtime delivery behind RLS.supabase.realtime.setAuth(access_token) before subscribing; without this RLS silently drops events.visibilitychange → router.refresh() is the safety net for silent socket drops.Works on both /invoices (list) and /invoices/[id] (detail). Any DB update from any other path (watcher, fast-path route, cron) appears on the owner's UI within ~1 second.
File: src/app/invoice/[id]/use-public-invoice-realtime.ts
postgres_changes. The anon
key has no SELECT policy on invoices at all (the audit found the prior
blanket policy exposed every non-draft invoice's full row, including
access_code, for far more than the payer page actually needed). Instead, a
trigger on invoices (migrations 0022_close_anon_exposures.sql,
0023_broadcast_guard_and_authorization.sql) calls
realtime.broadcast_changes() on every non-draft UPDATE, sending a minimal
{id, status, btc_txid} record to a broadcast channel named invoice:<id>.
Draft invoices never broadcast at all.{ config: { private: true } }. realtime.broadcast_changes() sends via
realtime.send(), which defaults broadcasts to private — an RLS policy on
realtime.messages (not invoices) authorizes anon to receive messages on
topics matching the invoice:<uuid> pattern.payload.payload.record (status, btc_txid) to the
page, which applies them to local React state — so the badge moves without a
router.refresh() (which would re-run the server fetch and clobber other
in-flight UI state).visibilitychange → router.refresh() is the safety net for silent socket drops.Publishing an invoice (creating its public URL) is decoupled from sending it via email. The owner picks how delivery should happen via a single split-button menu on the invoice detail page and the /invoices per-row dropdown.
Three columns on invoices capture delivery state without polluting the payment-status enum (which stays focused on pending → payment_detected → paid):
sent_at — non-null once the invoice has been "delivered" (manually or via successful email).send_method — 'email' or 'manual'; non-null when sent_at is set.email_attempted_at — set the moment a safeSend for type=invoice_published is fired, regardless of outcome. Used to gate the "Send via email" option.The menu shows only actions that are still useful for the invoice's current state:
| State | Trigger | Menu options |
|---|---|---|
| Draft | Publish | Send via email · Download and mark as sent · Mark as sent · Publish only |
Published-only (sent_at NULL) | Send | Send via email · Download and mark as sent · Mark as sent |
Manually-marked-sent (sent_at set, email_attempted_at NULL) | Send | Send via email (only — disabled with tooltip if no client_email) |
Email attempted but failed (email_attempted_at set, sent_at NULL) | Send | Send via email (disabled) · Download and mark as sent · Mark as sent |
Successfully delivered via email (sent_at + email_attempted_at both set) | (hidden) | — |
| Manually sent + email attempted (both set) | (hidden) | — |
| Archived | (hidden) | — |
Notes:
email_attempted_at is set, "Send via email" is permanently disabled with a tooltip — re-attempts would hit the same client_email, which is currently immutable post-publish.Send trigger disappears entirely once every path is a no-op (both sent_at and email_attempted_at set).development/ROADMAP.md as v1.4.18 — Resend Webhook: Sent vs Delivered vs Bounced.Three server actions back the menu (src/app/(dashboard)/invoices/actions.ts):
publishInvoice(id) — publish only, no delivery side-effect.publishAndSendEmail(id) — publish + fire invoice-published email; returns { emailStatus } so the UI can show success/failure.publishAndMarkSent(id, { withDownload? }) — publish + record manual delivery. With withDownload: true, returns { downloadUrl } so the client triggers the existing /api/invoices/[id]/pdf route.All three route through applyPublishUpdate, which calls decideOverdueFlip against the loaded invoice's due_date and writes status='overdue' directly when the date is already past — so a freshly published past-due invoice lands on overdue immediately rather than waiting for the next cron sweep. The activity feed records marked_as_overdue in the same write.
All transactional email goes through Resend via src/lib/email/send.ts. There are three email types, three triggers, and two recipient types.
Supabase auth emails (magic-link, signup confirmation, password reset) also route through Resend via Supabase's custom SMTP setting (dashboard → Project Settings → Auth → SMTP Settings, pointing at
smtp.resend.com:465). The custom SMTP Sender is set to[email protected]— the same addressEMAIL_FROMuses for transactional mail — so all SatSend mail (auth + invoicing) arrives with one consistentFrom:identity. Both auth email and transactional email depend on the Resend account / domain being healthy.
| # | Fires when… | Callsite (server) | Recipients | |
|---|---|---|---|---|
| 1 | Invoice published | Owner picks "Send now via email" from the publish/send menu | src/app/(dashboard)/invoices/actions.ts → publishAndSendEmail | Payer (client_email) |
| 2 | Payment detected | Status transitions → payment_detected | src/app/api/invoices/[id]/payment-status/route.ts or src/app/api/cron/payment-sweep/route.ts | Invoice owner and payer (client_email) |
| 3 | Payment confirmed | Status transitions → paid | Same two callsites as above | Invoice owner and payer (client_email) |
Notes:
payment-status-email.tsx, v1.5.28) with an audience of sender or client. It shows the same payment block as the client's invoice page (state icon, Requested / Received / Outstanding, next step, time in UTC, address, transaction), worked out once by src/lib/invoices/payment-summary.ts. The state icon travels inline like the logo (status-icons-inline.ts, built by scripts/email/build-status-icons.tsx).supabase.auth.admin.getUserById(invoice.user_id); the payer email is the invoice's client_email.client_email is blank on an invoice (payer email is optional), every payer-side send is silently skipped — including the invoice-published email and both payment-status emails. The owner copy still goes out.The fast-path API (triggered by the payer's mempool WebSocket) and the background cron can both observe the same state change. Without care, both would send the same email.
Deduplication works at the DB level, not the email level:
UPDATE invoices
SET status = 'payment_detected', …
WHERE id = :id
AND status = :priorStatus ← optimistic concurrency guard
Whichever path commits the status change first wins. The loser gets PGRST116 (0 rows affected), short-circuits, and never reaches the email dispatch. So a given pending → payment_detected transition fires exactly one "Payment detected" email regardless of how many watchers saw the tx.
The same applies to payment_detected → paid.
Edge case: if the pre-mempool cron finds a confirmed tx directly (rare — the tx hit a block before any cron tick saw it unconfirmed), it transitions pending → paid in a single step and sends only the confirmed email. No "detected" email fires because no row ever held payment_detected.
Every send goes through a safeSend wrapper (src/lib/email/send.ts) that:
console.warn) if RESEND_API_KEY is not set — so local development without Resend still works.console.error instead of throwing.A broken email provider never blocks a publish or a payment transition. The invoice state is the source of truth; email is best-effort delivery on top of it.
Every send attempt is recorded in the email_events table (migration 0010_email_events.sql):
| Column | Meaning |
|---|---|
email_type | invoice_published / payment_detected / payment_confirmed |
recipient | The address Resend was asked to send to |
status | queued → terminal sent / failed / skipped_no_api_key |
resend_message_id | Populated on sent from Resend's response — useful for cross-referencing the dashboard |
error_message | First 500 chars of the failure reason (Resend error or thrown exception) |
created_at / updated_at | Timestamps for the row insert and its terminal-status update |
safeSend (src/lib/email/send.ts) inserts a queued row before each Resend call, then flips the row to its terminal status when the send returns or throws. Both the insert and the update are best-effort: a failed DB write logs and continues so a broken email_events table never blocks a publish or a payment transition.
The table is owner-scoped via RLS (auth.uid() = user_id) and surfaces on /invoices/[id] as the Email activity card so the owner can answer "did the payer get the link?" or "why didn't I get a confirmation email?" without leaving the app.
The email_events row records what happened at our end (we asked Resend, Resend acknowledged or rejected). It does not yet record what happened on the recipient's end:
POST /api/webhooks/resend and update rows by resend_message_id. For now, that detail lives in the Resend dashboard.failed is terminal until someone manually triggers a resend.Migration 0008_background_payment_schedule.sql:
next_check_at TIMESTAMPTZ — when the cron should next process this row. NULL = not in the rotation.mempool_seen_at TIMESTAMPTZ — stamped the first time a paying tx is observed. Drives the pre-/post-mempool cadence branch.stage_attempt INT DEFAULT 0 — counter the scheduler uses to index into the delay tables.Partial index invoices_next_check_at_idx on next_check_at WHERE next_check_at IS NOT NULL keeps the cron's SELECT … WHERE next_check_at <= now() fast.
Migration 0025_payment_amount_awareness.sql (v1.4.19-H — see "Payment amount verification" above):
amount_received_sats BIGINT — sats actually received at the invoice's btc_address, summed from the paying tx's matching vouts. Populated once the payment clears the confirmation-depth gate.btc_price_at_detection NUMERIC — the BTC/currency price used to convert amount_received_sats to fiat, fetched at confirmation time.amount_received_fiat NUMERIC — amount_received_sats converted using btc_price_at_detection.overpaid BOOLEAN DEFAULT false — true when amount_received_fiat exceeds total_fiat beyond the tolerance band. The invoice status stays paid; this is a flag alongside it.SatSend is source available under the Functional Source License, Version
1.1, with an Apache 2.0 future licence (FSL-1.1-ALv2). You can read, audit,
run and modify the code for any purpose except offering a competing product or
service. Two years after each version is released, it becomes available under
the Apache License 2.0. See LICENSE.md and
fsl.software.