API Reference
All backend API endpoints grouped by domain. 151 handler files serving 166 route registrations, plus 21 schedulers and 4 workers (177 files in apps/infra/lambda/, counted 2026-08-14). Counted from ROUTE_CONFIGS in lib/stacks/route-configs.ts — distinct entry values, and paths x methods summed — rather than from api-stack.ts, which no longer holds the table (the route table moved out of the stack that mounts it). The previous figures here read 133/136 and were dated 2026-07-29. See the schedulers & workers list at the end.
Auth (11)
| Method | Endpoint | Description |
|---|---|---|
POST | /auth/admin-2fa/verify | Verify an admin TOTP 2FA code |
POST | /auth/admin-login | Admin login with credentials |
POST | /auth/admin-oauth | Admin login via Google OAuth |
POST | /auth/admin/logout-all | Revoke all of the caller's sessions ("log out of all devices"). Owner-scoped; every admin role incl. FUND_MANAGER |
GET | /auth/admin/sessions | List the caller's non-expired active admin sessions (logged-in devices). Every admin role incl. FUND_MANAGER — owner-scoped by construction |
POST | /auth/admin/sessions/{id}/revoke | Revoke a single admin session (per-device logout). Owner-scoped; every admin role incl. FUND_MANAGER |
POST | /auth/logout | Clear the caller's HttpOnly refresh-token cookie, and the pre-split shared one (web + admin sign-out) |
POST | /auth/nonce | Request a random nonce for SIWE message signing |
POST | /auth/refresh | Exchange the HttpOnly refresh cookie for a new access token. Request-body refresh tokens are rejected. Access and refresh JWTs carry distinct, mandatory token_use claims. Per front-end: the cookie is picked by Origin (refresh_token_admin for admin-web, refresh_token_investor otherwise, falling back to the pre-split refresh_token), and a token whose role does not match the calling app is a 401 — one app can never refresh into the other's identity. |
POST | /auth/verify | Verify signed SIWE message; returns access token in body + refresh token as an HttpOnly cookie |
POST | /auth/verify-email | Verify an investor's email address via emailed token |
Users (8)
| Method | Endpoint | Description |
|---|---|---|
GET | /users | List users (with filters) |
GET | /users/me | Get the current authenticated user's profile |
POST | /users/me/email | Set / update the current user's email (triggers verification) |
GET | /users/me/kyc-status | Check KYC status for current authenticated user |
GET | /users/me/notification-preferences | The investor's own notification toggles |
PUT | /users/me/notification-preferences | Update the investor's notification toggles |
GET | /users/{id} | Get user details by ID |
GET | /users/{id}/investor-detail | Admin/operator deliberate PII + holdings reveal. The server persists PII_ACCESS first and returns no detail if auditing fails. |
Wallets (2)
| Method | Endpoint | Description |
|---|---|---|
GET | /wallets | List wallets (with filters) |
GET | /wallets/{address} | Get wallet details by address |
KYC Logs (2)
| Method | Endpoint | Description |
|---|---|---|
GET | /kyc-logs | List KYC verification logs |
GET | /kyc-logs/{id} | Get KYC log details by ID |
KYC / SumSub (5)
| Method | Endpoint | Description |
|---|---|---|
POST | /kyc/access-token | Generate SumSub SDK access token for KYC/KYB flow |
POST | /kyc/mint-sbt | Mint Soulbound Token after KYC approval. { userId } — self or operator. Add force: true (operator only) to re-issue a credential that is already MINTED: the worker burns the existing token and mints a replacement (new token id + validity), audited as SBT_REMINT. Rejected with 409 while another mint is in flight. |
POST | /kyc/reuse | Import a partner-shared KYC via share token (cross-VASP reuse, v3-17) |
POST | /kyc/sync | Pull the current SumSub review status now and apply it (returning / reused applicants — skips the reconcile-sweep wait). Body { userId? }: omitted → the caller syncs themselves; an operator may pass another user's id, which is the admin lever for a row stuck in IN_REVIEW after a lost webhook |
POST | /kyc/webhook | SumSub webhook — updates kyc_status on verification result |
Pools (18)
| Method | Endpoint | Description |
|---|---|---|
GET | /pools | List all pools (with status, NAV, APY, capacity) |
POST | /pools | Create new pool with config (APY, lock-up, reserve %, etc.) — triggers deploy worker |
GET | /pools/{id} | Get pool details by ID |
PATCH | /pools/{id} | Update pool details (Class B fields rejected once deployed) |
DELETE | /pools/{id} | Two modes, one endpoint (`?hard=true |
POST | /pools/{id}/close | ✅ Shipped (v3-110 B). action: close / reopen (+ reason). Ends the subscription — blocks deposits only, leaving redemptions and yield claims fully open — and is reversible via reopen, deliberately narrow: only a pool that is currently CLOSED can reopen, so it cannot be used to undo maturity or wind-down. Exists because CLOSED previously had no write path at all: a documented lifecycle state nothing in the codebase could reach. The scheduler half (auto-close at end_date) ships with it in pools.scheduler.lifecycle |
PATCH | /pools/{id} → restore | ✅ Shipped (v3-110 A). There is no POST /restore — un-archiving is a PATCH that clears deleted_at, and reason is a mandatory field recorded on a dedicated POOL_RESTORE audit event (rejected on any other edit). Archive itself now emits POOL_ARCHIVE, so archive / restore / permanent delete are three distinguishable events instead of one shared POOL_DELETE |
GET | /pools/{id}/eligibility | Pre-flight per-pool KYC gating — mirrors the invest-time gate so the UI can warn before a deposit attempt |
POST | /pools/{id}/freeze | Emergency freeze controls. action: freeze / unfreeze only (PAUSER, instant). freeze requires a reason (v3-86) and mirrors freeze_started_at; the freeze is time-bound (exits auto-unblock after 72h, whole freeze auto-expires after 7d, v3-28). ⚠️ propose_extend / execute_extend / cancel_extend return 410 Gone — the on-chain extension was removed (its 7-day timelock equalled the 7-day freeze lifetime, so a proposal only became executable after the freeze had already lapsed, and executing it then either did nothing or retroactively re-locked the pool and restarted the 72h exit block). The 410 body names the two replacements: let the freeze lapse then POST /pools/{id}/pause to keep deposits closed, or POST /pools/{id}/impairment for partner distress. |
GET | /pools/{id}/fund-data/history | Joob fund performance history for the pool |
GET | /pools/{id}/fund-data/summary | Joob fund data summary for the pool |
GET | /pools/{id}/fund-data/writeoffs | Loan write-off history for the pool |
POST | /pools/{id}/governance | Governance-timelocked config change — propose / execute / cancel for fund_wallet, reserve %, treasury, jurisdiction and redemption gating. ⚠️ redemption_gating_bps is deprecated — not included in MVP (2026-08-27, not yet reflected, v3-150): it comes off the screens and the API, but the on-chain timelocked triple stays, so this endpoint keeps the leg. → 04 → redemption_gating_bps |
POST | /pools/{id}/epoch-funding | Fund a whole epoch cycle (v3-119). Credits epochFundTopUp[currentEpochId], which is what Pool.fundRedemption already did on an epoch pool while ignoring the request id it was handed — so the per-request route was a shape mismatch, not a different mechanism. Exactly one of amount (platform key signs; only works where the fund wallet is that key, since fundRedemption is onlyRole(YIELD_DEPOSITOR_ROLE)) or fund_tx_hash (the FM signed from the fund wallet; the endpoint verifies the receipt touched this pool and records it). ADMIN / SUPER_ADMIN / OPERATOR / FUND_MANAGER, fund-scoped. Writes nothing to redemption_requests — the credit belongs to the cycle, so stamping N queued rows with one funding tx would misattribute it N times; the record is POOL_EPOCH_FUNDED in the activity log. Response carries the post-funding epoch_id / topup_usd / shortfall_usd read back from chain (18-decimal normalized), or snapshot_unavailable: true when that read failed. |
POST | /pools/{id}/epoch-schedule | Per-cycle schedule knobs (v3-107). action: confirm_funding_date targets the cycle currently accepting requests; action: delay_settlement targets the cycle awaiting settlement. Chain first, DB second — a row claiming a confirmation the chain does not have is the v3-92 defect class. New: optional epoch_id (v3-139) — omitted, the server resolves acceptingEpochId exactly as before, so the one existing caller is unchanged; supplied, it confirms that cycle, which is what a repayment plan needs when the deploy's write run was cut short. A cycle behind the accepting one returns 409 rather than a decoded WindowAlreadyOpen. 🔴 pools.next_funding_date and its two provenance columns are mirrored only when the target is the accepting cycle — they describe one cycle, and filling them from a cycle-4 write would have the pool announce cycle 4 as its next funding date and mark it confirmed before its window opened. The response therefore carries mirror_scope (accepting_cycle / future_cycle_no_pool_row) as well as mirrored, because otherwise mirrored: false means both "the mirror failed" and "there was nothing to mirror", and a caller retrying on the first sends a second transaction to a chain that already agreed |
GET | /pools/{id}/repayment-cycles | One row per cycle of a post-maturity repayment plan (v3-133). Returns { pool_id, term, funding_anchor_date, epoch_duration_days, cycles: [{ epoch_index, funding_date, settled_at, fill_ratio, settled_nav }], unwritten_cycles }. Exists because epoch-summary is one row per pool about the cycle the cursor is on, and a plan has to be shown whole. 🔴 It deliberately does not read the chain — a chain read answers for every cycle and erases the only distinction the caller came for, which is funding_date: null meaning nobody set this. Rows are returned for 1..term whether or not the ledger has them (a cycle nothing was recorded about is the case worth showing); indices beyond term are dropped; term: null means not a plan and is distinct from a plan with no records yet; and a ledger read failure is a 500, never [], because an empty list reads as "no cycle has a date" and the confirmation card would then offer to write all of them, one transaction each. unwritten_cycles comes from the same function the funding-date reminder uses. Does not read pools.next_funding_date — that column describes the accepting cycle and would answer a cycle-4 question with a cycle-2 date. ADMIN / SUPER_ADMIN / OPERATOR / FUND_MANAGER, fund-scoped |
POST | /pools/{id}/impairment | Propose / cancel impairment (7-day timelock; IMPAIRED lifecycle) |
POST | /pools/{id}/lifecycle | Manual publish transition (DRAFT → UPCOMING / ACTIVE) — other transitions use dedicated endpoints. Also the Retry Deploy route: a pool that is already published with deploy_status = DEPLOY_FAILED re-enters DEPLOYING at its existing status (pools.post.lifecycle.ts:62-78). ⚠️ lifecycle_status = ACTIVE with deploy_status = DEPLOY_FAILED is the intended pair, not a drift to repair — lifecycle records the operator's publish intent, deploy_status records whether the chain caught up, and the retry needs the first preserved to land on the original target. Reverting lifecycle to DRAFT on failure would take the fresh-publish branch instead and lose the chosen target. The combination is contained: pool_address is written only on a successful deploy, so every on-chain write path refuses; investors are filtered on both the API (pools.get.list.ts:143,272) and the client; and the admin chip shows Deploy failed, which outranks the lifecycle chip (pool-badges.tsx:103). |
POST | /pools/{id}/preview-token | Mint a short-lived (15m) pool-scoped token so an internal role (operator / FM own-fund / admin) can view a DRAFT pool via the real investor PDP (GET /pools/{id}?preview=<token>) |
POST | /pools/{id}/pause | Soft pause / unpause deposits — wires on-chain pause()/unpause(). ⚠️ Pausing requires lifecycle_status = ACTIVE and a non-frozen pool, so a DRAFT pool can never be paused (400) — the higher states already block deposits, and stacking pause under them re-creates the multi-status confusion A1 removed. Unpausing is always allowed so a pool can recover. The DB-only path is therefore not "DRAFT" but ACTIVE + not yet deployed (deploy_status of DEPLOYING or DEPLOY_FAILED): there is no contract to call, so the flag lives only in the DB. The DEPLOY_FAILED → Retry window is where that matters — a pause taken there is not mirrored on-chain, and nothing re-applies it after a successful retry (gap G2) |
POST | /pools/{id}/wind-down | Propose / execute wind-down (terminal, pro-rata redemption only) |
GET | /pools/{poolId}/nav-history | Get NAV history for a pool |
Repayment total failure reason
GET /pools/{id}/repayment-cycles includes unknown_reason in a non-null total_due_at_maturity. It is CHAIN_READ_FAILED when the principal read fails, INVALID_PRINCIPAL for a negative or non-finite principal, INVALID_ACCRUAL_RATE for a negative or non-finite fixed rate, or INVALID_MATURITY_DAYS for a missing, non-finite, or non-positive fixed-interest term. These failures return overdue_status: "UNKNOWN" and null monetary fields. Only the first failing validation is reported; no additional chain reads are made.
Otherwise unknown_reason is null, including TARGET pools without fixed interest, unpriced overdue, and genuine zero amounts. When the endpoint intentionally omits the target (for example, before maturity or for an investor), total_due_at_maturity itself remains null. Older API deployments may omit unknown_reason; the admin screen keeps a general unavailable-data notice for legacy UNKNOWN responses.
🃏 Card-content fields on GET /pools
The list response carries the fields the investor browse card renders, not just the numeric summary: tagline, term, eligibility_mode, is_showcase, and funds.verified alongside the redemption-state fields. They are part of the list contract because a missing column does not render as "unknown" on the card — it renders as a wrong default (no verified checkmark, no professional-investor chip, and the card pitch silently falling back to description while the detail page shows tagline). GET /pools/{id} additionally returns apy_disclosure, which the PDP renders under the target APY.
❄️ Freeze fields on pool responses (v3-28)
GET /pools and GET /pools/{id} expose is_emergency_frozen, freeze_started_at, and two derived convenience fields computed from freeze_started_at + the on-chain constants (no extra read): freeze_exit_window_ends_at (start + 72h — when value-OUT auto-unblocks) and freeze_auto_expires_at (start + 7d — when the whole freeze lapses). Both derived fields are null when the pool is not currently frozen (freeze_started_at is null). Backed by migration 0023 (pools.freeze_started_at).
Pool Updates (4)
| Method | Endpoint | Description |
|---|---|---|
PATCH | /pool-updates/{id} | Edit a pool update / announcement |
DELETE | /pool-updates/{id} | Delete a pool update / announcement |
GET | /pools/{id}/updates | List a pool's updates / announcements |
POST | /pools/{id}/updates | Create a pool update / announcement |
Pool Categories (3)
| Method | Endpoint | Description |
|---|---|---|
GET | /pool-categories | List pool categories |
POST | /pool-categories | Create pool category |
DELETE | /pool-categories/{id} | Delete a pool category |
Pool Data (5)
| Method | Endpoint | Description |
|---|---|---|
GET | /pool-documents | List pool documents |
GET | /pool-documents/{id} | Get pool document by ID |
GET | /pools/{poolId}/tvl-history | Get TVL history for a pool |
GET | /underlying-assets | List underlying assets |
GET | /underlying-assets/{id} | Get underlying asset details by ID |
Deposits (3)
| Method | Endpoint | Description |
|---|---|---|
GET | /deposits | List deposits. Filters: status, pool_id, user_id, q (investor_address / tx_hash / pool name / investor name). status accepts a comma-separated list (status=PENDING,PROCESSING — the admin Pending tab, which labels both "Pending"); any value outside the deposit_status enum → 400. Paginated via limit/offset + X-Total-Count. |
POST | /deposits | Create new deposit (atomic on-chain: USDC into Pool + LP mint — no escrow/receipt, v3-03/v3-11) |
GET | /deposits/{id} | Get deposit details by ID |
Redemption Requests (10)
Redemption is non-custodial: FM client-signs fundRedemption; the payout auto-executes on-chain. See 07-redemption, v3-53.
| Method | Endpoint | Description |
|---|---|---|
GET | /redemption-requests | List redemption requests (with filters). Epoch pools also surface epoch_id, lp_filled, settled_nav, fill %, claimable amount, and next-settlement time. Filters: status, pool_id, user_id, funding, epoch, and (R1, for the EPOCH-tab per-queue infinite scroll) epoch_open=true (status QUEUED/PARTIALLY_FILLED) + funding_status (e.g. HELD). Paginated via limit/offset + X-Total-Count. |
GET | /redemption-requests/epoch-summary | Epoch pools per-pool demand aggregation (R1). Server-side SUM/COUNT (SQL fn redemption_epoch_summary, migration 0084) over the full open-demand set (QUEUED/PARTIALLY_FILLED): carried demand (LP + USD, excl. HELD), open/held/rollover counts, next- and last-settlement (fill %, NAV). Drives the admin Redemptions EPOCH-tab cards; the individual queue rows page in separately via the list endpoint. One entry per epoch pool (incl. zero-demand pools), FM-scoped. Three fields added for repayment plans (v3-133): maturityDate (the deploy-set date mirrored from chain — ⚠️ not interchangeable with created_at + maturity_days, which is the wizard's pre-deploy estimate), unwrittenCycles (ascending cycle ids still deriving their date; [] when the pool has no plan) and hasUnwrittenCycles. The name is literal — not written, not "unconfirmed" or "pending" — and it comes from the same function the reminder gates on, so a consumer has nothing left to interpret. |
GET | /redemption-requests/counts | Per-tab counts for the admin Redemptions screen, honoring the same read scope + filters as the list endpoint. Also returns the v3-99 closure breakdown: closures.{UNFUNDED, COMPLIANCE, OTHER, INVESTOR_CANCELLED, UNCLASSIFIED} plus CLOSED derived as their sum. REJECTED is one status carrying four outcomes, so a single "rejected" figure is misleading — an investor withdrawing their own request would read as the platform refusing one. UNCLASSIFIED = pre-split legacy rows + NULLs from on-chain-direct rejects the indexer mirrored. |
POST | /redemption-requests | Submit redemption request. Instant: locks NAV snapshot. Epoch: enrolls into current epoch (no snapshot), epoch_id recorded, status QUEUED. |
GET | /redemption-requests/{id} | Get redemption request details by ID |
POST | /redemption-requests/{id}/approve | Instant: admin approve (reserve check → payout). Epoch: mirrors holdRequest/releaseRequest (anomaly hold), not per-request approval. |
POST | /redemption-requests/{id}/cancel | Investor cancels their own in-flight epoch request (QUEUED/PARTIALLY_FILLED). 🔴 Instant-path cancellation is out of MVP scope (decided 2026-08-27 — not yet reflected, v3-149; the on-chain branch still exists, and it covers both REQUESTED and PENDING_RESERVE — v3-76). On-chain cancelRedemption is investor-gated (wallet call); this mirrors the confirmed cancellation, collapsing DB status to REJECTED (failure_type = INVESTOR_CANCELLED — per decision A, CANCELLED is not a stored enum value). |
POST | /redemption-requests/{id}/claim | Epoch pools: mirror of on-chain claimRedemption (RedemptionClaimed event) — settles lp_filled / settled_nav / payout / status, rolls remainder over. Permissionless on-chain (claim-on-behalf, fixed destination). |
POST | /redemption-requests/{id}/claim-fallback | 🔴 Removed 2026-08-31. It mirrored the on-chain claimRedemptionFallback, which is gone with the reserve it drew on. The route is removed rather than left to error: a path that reaches a handler which reverts teaches a holder the exit is broken, and a 404 is the truth. |
POST | /redemption-requests/{id}/record-funding | The only funding path. Records the FM's on-chain fundRedemption tx (v3-53); for an instant pool it settles inline when the receipt already carries RedemptionCompleted, otherwise the indexer settles. ⚠️ POST /redemption-requests/{id}/fund (server-key) was removed 2026-08-13 — fundRedemption is onlyRole(YIELD_DEPOSITOR_ROLE), the partner's role, and the platform could only satisfy it through the deploy-time self-grant v3-53 called a custodial dev shim. |
POST | /redemption-requests/{id}/reject | Return position (v3-99) — ADMIN / SUPER_ADMIN only (FM excluded). Closes a REQUESTED/PENDING_RESERVE request: escrowed LP returns to the investor, partner funding returns to fund_wallet. Body requires both failure_type (UNFUNDED | COMPLIANCE | OTHER — validated; INVESTOR_CANCELLED rejected, /cancel owns it) and a non-blank reason. Status → REJECTED; the category selects the investor notice. Path and enum keep the old reject/REJECTED names — renaming both for a label change is not worth the migration. |
GET | /redemption-requests/{id}/exit-gate | ADMIN / SUPER_ADMIN only. Advisory, read-only: may this request's holder still be paid? Reads the on-chain request tuple + canRedeem at call time (not from the hourly sweep, which can be an hour stale) so the Return position dialog can pre-select COMPLIANCE. Returns { known, blocked, kycState, suggestedReason }; known: false for an epoch pool, a request with no on-chain id, or an unreadable chain — the caller must keep its own default rather than read that as "not blocked". Changes nothing. |
⚠️ Removed
POST /redemption-requests/{id}/fm-accept (v3-34 — no FM pre-acknowledge step) and POST /redemption-requests/{id}/complete (v3-53 — the indexer settles via RedemptionCompleted; a /complete endpoint would race it) are not part of the v3 flow, nor are the legacy multi-sig co-sign / execute-transfer endpoints.
Portfolio Positions (2)
| Method | Endpoint | Description |
|---|---|---|
GET | /portfolio-positions | List portfolio positions for current user |
GET | /portfolio-positions/{id} | Get portfolio position details by ID |
Yield Distributions (5)
| Method | Endpoint | Description |
|---|---|---|
GET | /yield-distributions | List yield distributions |
POST | /yield-distributions | Create yield distribution for a pool (period, total_amount) |
GET | /yield-distributions/me | Get yield distributions for current investor |
GET | /yield-distributions/{id} | Get yield distribution details by ID |
POST | /yield-distributions/{id}/distribute | Verify the FM's on-chain depositYield then run the server-key settleYield (v3-53, v3-102) |
GET | /yield-distributions/preview | Price a typed gross without recording it — ?pool_id=&gross_amount= (v3-104) |
GET | /yield-distributions/fund-summary | By-fund rollup of what is owed, plus each fund's FM-notify state (v3-121) |
GET | /yield-distributions/{id}/investors | Per-investor split for one distribution, plus the pool's unclaimed balance (v3-121) |
Escrow accrual read failures
DUE rows include escrow_accrual_total and escrow_accrual_unknown_reason. The latter is null after a complete read, including a real zero or no open requests. A missing request identity or an incomplete sum returns PARTIAL_READ; transport failures return CHAIN_READ_TIMEOUT, CHAIN_READ_RATE_LIMITED, or CHAIN_READ_FAILED. Failure to list open requests returns REQUESTS_READ_FAILED. NOT_AVAILABLE means the read is not available for that pool, including TARGET pools without a fixed obligation. Amounts remain null when unavailable; partial sums are never returned.
For a FIXED due row whose complete obligation is unavailable, the admin queue shows —, the failure cause, and Retry. The booked amount remains under Booked, never in place of the total. Individual and batch deposits are blocked until the read succeeds. Unknown eligibility is presented as unverifiable, not as no holders. Retrying refreshes both the visible queue and the full queue used by the deposit dialog and batch review; it does not write to the chain or database. TARGET funding and pools outside the due queue retain their existing paths.
GET /yield-distributions?include_due=true — due periods in the read model (v3-104, shipped 2026-08-03)
A period that has come due but has no row yet exists only as pools.next_yield_due / yield_overdue — there is no scheduled-period table. include_due=true makes the list return recorded rows plus synthesized due rows, discriminated by source: 'RECORD' | 'DUE', so the admin Yield "Pending" tab reads the same endpoint as the other four tabs instead of joining GET /pools in the browser.
Default false — omit it and the response is byte-identical to before (investor /me, CSV export and the other tabs are unaffected; the extra fields are attached only when the flag is on).
Interaction with status: omitted → records + due rows; status=PENDING → due rows only (that status is impossible for a distribution since migration 0113); any other status → records only, flag ignored.
A due row carries pool_id (its key — id is null, it has no ledger identity), pool_name, the backend-authoritative period label, due_at, is_overdue, overdue_days, missed_periods (cadence intervals owed; the sweep never rolls a missed date forward, so a 4-months-late pool is one row saying 4), and estimated_gross / estimated_net / estimate_basis: 'APY_ON_TVL'. The estimates are server-computed from the same computeYieldFees a real settlement uses and cover all missed periods; they are null when the pool has no TVL or no APY to estimate from — render —, not $0. Ledger columns (total_amount, status, tx_hash, …) are null.
⚠️ The window reaches 7 days ahead of the due date since v3-120, so a due row is not necessarily overdue. Filter on is_overdue for anything that means "already owed". overdue_days is clamped at 0 and reports 0 for both an approaching period and one due today, so it cannot stand in.
Sorting adds due_at, which orders records by distributed_at ?? created_at and due rows by next_yield_due in one ordering. q matches pool name on due rows (they have no tx_hash). X-Total-Count spans both kinds. FM fund scoping applies to due rows exactly as to records.
GET /yield-distributions/fund-summary — which fund to chase (v3-121, 2026-08-07)
One row per fund holding an outstanding period, worst first: fund_id · fund_name · fm_count · pool_count · owed_estimated · behind_pools · next_due_at · next_due_pool_name · notify_state · last_escalated_at. Built on the same fetchYieldDueRows the Pending queue reads, so the By-Fund totals and the screen's exposure card cannot drift; grouping in the browser is exactly how they would. owed_estimated and behind_pools count is_overdue rows only.
A pool with no fund_id (legacy — creation has required one since v3-26) lands in a fund_id: null bucket rather than being dropped, so the fund totals still sum to the exposure figure.
notify_state is derived, not stored, and its three values are not interchangeable:
| Value | Meaning | The fix |
|---|---|---|
UNREACHABLE | No active FM on the fund, or the escalation reached nobody | Assign an FM. Re-notifying does nothing |
NOTIFIED | Escalated and delivered to at least one person | Wait on the fund wallet |
NONE | Reachable, nothing sent yet | Escalate |
⚠️ yield_distributions.fm_notified_at is not the field for this: run-distribution.ts stamps it when a distribution actually runs, so it is null for exactly the unpaid periods this table is about. Escalation (POST /pools/{id}/escalate-yield) writes no column at all; the durable record is its notification_events row (subject_type='pool'). And notification_events alone is not enough — dispatch.ts inserts the event before it resolves the audience, so escalating a fund with no FM still leaves an event row. The notifications join is what separates UNREACHABLE from NOTIFIED; without it an operator waits on a signature request that was never delivered.
GET /yield-distributions/{id}/investors — who received it (v3-121, 2026-08-07)
{ distribution_id, pool_id, net_total, investor_count, truncated, investors[], pool_unclaimed_total, pool_distributed_total }. Each investor row is { user_id, name, address, amount, share_percentage }, ordered by amount, capped at 50 with truncated: true beyond that — the totals are computed over every row, so a capped list still yields a share bar that closes at 100%. share_percentage is recomputed against this round's own total rather than read from the stored column, which is null on legacy rows.
PII: viewInvestor (docs 09-rbac) is applied, so an FM sees name + wallet and never email. Fund scoping matches the record list: an FM reading a distribution outside their funds gets 403.
⚠️ pool_unclaimed_total is POOL-level and cumulative, not per round. Nothing records whether a specific round's share was claimed — yield_distribution_investors.claimed_at exists but has no writer. What is tracked is portfolio_positions.accrued_yield, credited by increment_accrued_yield at settle and drawn down on claim / reinvest, so its sum over the pool is a real unclaimed balance across all rounds. The receipt labels it that way; presenting it as "unclaimed this round" would be a number nothing backs.
Do not read yield_distribution_investors.investor_name / .investor_address either: the columns exist, and run-distribution.ts writes neither. Names come from the users join.
GET /yield-distributions/preview — pricing a gross the operator typed (v3-104, 2026-08-03)
?pool_id=<uuid>&gross_amount=<number> → { gross_amount, period_days, fee_configured, treasury_fee, pool_mgmt_fee, fee_total, net_amount, legs: { platform_take, spc_mgmt, pool_mgmt, perf }, clamped }. Read-only — no DB write, no on-chain call. It is computeYieldFees behind an endpoint, so the figure the record dialog previews is the figure the create endpoint will apply.
Why it has to be server-side. The due-row estimate only covers the period the backend knows is due, and net is not proportional to gross: the management legs are charged on AUM (aum × bps/10000 × days/365) and do not move with gross at all. Measured on a 7.1M-TVL pool with SPC 0.5% + pool 0.75%/yr, net/gross is 0.706 at a $100k gross and 0.410 at $20k — so scaling the estimate client-side is wrong, and at $5k the fees exceed gross entirely, clamped is true and net floors at 0. Deriving it in the browser would also mean the front end encoding which legs are proportional, which is the duplication v3-104 removed.
Same authorization as recording: requirePagePermission('yield') + requireFundAccess, so an FM cannot price a pool outside their funds (the response is derived from that pool's fee config and TVL). Deliberately not gated on lifecycle_status / deleted_at / deploy_status — it computes a number rather than asserting that recording is permitted; POST /yield-distributions still rejects those pools.
⚠️ It mirrors the create handler's no-period path (periodDays from the pool cadence via yieldPeriodDays), which is what the dialog sends. If the dialog ever gains period_start/period_end inputs, both handlers must take them or the preview starts lying.
POST /yield-distributions requires deposit_tx_hash (v3-104, 2026-08-03)
The legacy all-in-one path (no deposit_tx_hash, server key signs depositYield and settles in the same invocation) was removed — it was the only writer that ever inserted PENDING, and a row left there was an unhealable crash orphan. The endpoint now 400s without the FM's client-signed deposit tx, and creates the row in PROCESSING for POST /{id}/distribute to finish. body.deposit is gone.
Yield Claims (3)
| Method | Endpoint | Description |
|---|---|---|
GET | /yield-claims | List yield claims |
POST | /yield-claims | Create yield claim (investor claims accrued yield) |
GET | /yield-claims/{id} | Get yield claim details by ID |
Yield Reinvest (1)
| Method | Endpoint | Description |
|---|---|---|
POST | /yield/reinvest | Re-deposit claimed yield into pool → new LP minted. ⚠️ Reinvest is not offered on any screen in MVP (2026-08-27, not yet reflected, v3-151) — the CTA comes off the investor app and the toggle off admin. The endpoint and the on-chain reinvest() both stay; what closes the path is allow_rollover's false default, which makes the call revert RolloverDisabled |
NAV Changes (5)
| Method | Endpoint | Description |
|---|---|---|
GET | /nav-changes | List NAV change proposals |
POST | /nav-changes | Propose NAV change (timelock if a decrease). Body takes either cumulative_loss (preferred — the price is derived through the pool's buffer, 0122) or new_nav directly, never both. dry_run: true computes and simulates without writing |
GET | /nav-changes/{id} | Get NAV change details by ID |
POST | /nav-changes/{id}/activate | Activate proposed NAV change after timelock |
POST | /nav-changes/{id}/cancel | Cancel pending NAV change proposal |
Tranche Writedown (1)
| Method | Endpoint | Description |
|---|---|---|
POST | /tranche-writedown | Apply a tranche-level loss waterfall / NAV writedown |
Funds (5)
| Method | Endpoint | Description |
|---|---|---|
GET | /funds | List funds. Embedded fund_members are ACTIVE-only and embedded pools exclude soft-deleted rows (both counts were inflated before). Paginated via limit/offset (default 50, max 200) |
POST | /funds | Create new fund. name must be non-blank; primary_contact_email is format-checked. verified and notification_health are not accepted (trust signal / nothing maintains that column) |
GET | /funds/{id} | Get fund details by ID. Adds linked_pool_total — pools pointing at the fund including soft-deleted ones, i.e. what blocks DELETE |
PATCH | /funds/{id} | Update fund details. Same name / email / verified / notification_health rules as POST |
DELETE | /funds/{id} | Delete fund |
Fund Members (5)
| Method | Endpoint | Description |
|---|---|---|
GET | /fund-members | List fund members |
POST | /fund-members | Add fund member |
GET | /fund-members/{id} | Get fund member details by ID |
PATCH | /fund-members/{id} | Update fund member |
DELETE | /fund-members/{id} | Remove fund member |
Fund Invites (5)
| Method | Endpoint | Description |
|---|---|---|
GET | /fund-invites | List fund-member invites (by fund / status) |
POST | /fund-invites | Create a fund-member invite |
POST | /fund-invites/accept | Accept a fund invite (by code) |
GET | /fund-invites/code/{code} | Look up a fund invite by its code |
DELETE | /fund-invites/{id} | Revoke a fund invite |
Admin Users (7)
| Method | Endpoint | Description |
|---|---|---|
GET | /admin-users | List admin users |
POST | /admin-users | Create admin user |
POST | /admin-users/invite | Invite admin user via email |
GET | /admin-users/{id} | Get admin user details by ID |
PATCH | /admin-users/{id} | Update admin user |
DELETE | /admin-users/{id} | Delete admin user |
PATCH | /admin-users/{id}/permissions | Update an operator's page-level permissions (admin_user_permissions) |
Admin Wallets (3)
FM/admin signing-wallet binding (v3-53/v3-54, B3). SIWE bind is session-less (no JWT); authz SoT stays on-chain (msg.sender == pool.fund_wallet) — these are the display/audit/pre-sign cache.
| Method | Endpoint | Description |
|---|---|---|
GET | /admin/me/wallets | List the caller's own SIWE-bound wallets (scoped by auth; no cross-user reads) |
POST | /admin/wallet/nonce | Request a SIWE nonce to prove ownership of a signing wallet (bind-only, no JWT) |
POST | /admin/wallet/verify | Verify the SIWE signature and bind the wallet to the caller's account (admin_user_wallets) |
Admin Console (10)
Admin/operator console — in-app notifications, platform settings, and the admin's own profile + notification preferences.
| Method | Endpoint | Description |
|---|---|---|
PATCH | /admin/me | Update the caller's own display profile (name / avatar_url only). Open to every admin role — the target is always auth.sub, and role/email/wallet are not accepted, so it carries no authority. Account management (roles, email, wallet) stays on PATCH /admin-users/{id}, which is SUPER_ADMIN/ADMIN-only |
GET | /admin/me/notification-preferences | The caller's own admin notification toggles + the admin event catalog they are rendered from ({ events, preferences }) |
PUT | /admin/me/notification-preferences | Update the caller's admin notification toggles. category = notification event_key; only catalog-optional events accepted (critical is rejected, not coerced) |
GET | /admin/signer-balances | 🔴 SUPER_ADMIN only. Gas held by each of the four KMS signing keys (admin/minter/oracle/pauser), per supported chain, with the on-chain roles each key holds. Read-only, and deliberately absent from ROUTE_SIGNER_ROLES — it reads balances, so it gets the shared non-signing execution role. A key with no gas still holds its role and still builds a transaction, so the failure arrives as a revert mid-operation; this is the surface for seeing it first. balance: null means the read failed and is NOT zero |
GET | /admin/notifications | List the admin's in-app notifications |
PATCH | /admin/notifications/read-all | Mark all admin notifications as read |
GET | /admin/notifications/unread-count | Unread admin notification count |
PATCH | /admin/notifications/{id}/read | Mark one admin notification as read |
GET | /admin/settings | Get platform settings |
PUT | /admin/settings | Update platform settings |
Dashboard (1)
| Method | Endpoint | Description |
|---|---|---|
GET | /dashboard/stats | Get dashboard statistics (TVL, deposits, redemptions, users) |
Activity (3)
| Method | Endpoint | Description |
|---|---|---|
GET | /activity-events | List activity events |
GET | /activity-events/counts | Count activity events by severity for filter badges |
GET | /activity-events/export | Export activity events as CSV |
Config (2)
| Method | Endpoint | Description |
|---|---|---|
GET | /config/chains | List supported chains (chain_id, RPC / explorer metadata) |
GET | /config/stablecoins | List supported stablecoins and their contract addresses |
Notification Logs (3)
| Method | Endpoint | Description |
|---|---|---|
GET | /notification-logs | List notification logs |
GET | /email-suppressions | Addresses SES reported as a hard bounce or complaint (0110). OPERATOR needs the notifications page permission |
POST | /email-suppressions | Suppress an address by hand (reason='MANUAL'). ADMIN/SUPER_ADMIN only, note required, audited (EMAIL_SUPPRESSION_ADD). 409 if already suppressed |
DELETE | /email-suppressions/{email} | Lift a suppression so the address can be mailed again. ADMIN/SUPER_ADMIN only, audited (EMAIL_SUPPRESSION_REMOVE) |
POST | /notification-logs/{id}/escalate | Escalate a notification |
POST | /notification-logs/{id}/resend | Resend a failed notification |
Investor Notifications (4)
Investor in-app notification center + the investor's own notification preferences.
| Method | Endpoint | Description |
|---|---|---|
GET | /notifications | List the investor's in-app notifications |
PATCH | /notifications/read-all | Mark all investor notifications as read |
GET | /notifications/unread-count | Unread investor notification count |
PATCH | /notifications/{id}/read | Mark one investor notification as read |
Schedulers & Workers
Non-HTTP Lambdas (EventBridge cron / SQS / async invoke), not part of the endpoint count above.
| Type | Name | Description |
|---|---|---|
| SCHED | pools.scheduler.lifecycle | Pool lifecycle transitions (UPCOMING→ACTIVE, ACTIVE→MATURED), hourly |
| SCHED | pools.scheduler.tvl-snapshot | Daily TVL-snapshot sweep — the sole writer of pool_tvl_history |
| SCHED | pools.scheduler.yield-due | FM/Operator yield-due notifications (manual-trigger model) |
| SCHED | redemption-requests.scheduler.epoch-execute | Epoch settlement sweep (hourly) — executeEpoch per epoch pool (v3-26) |
| SCHED | redemption-requests.scheduler.partner-funding | Partner-funding reminders (hourly, v3-26 §A5.1) |
| SCHED | yield.scheduler.reconcile | Yield reconciler (hourly) — reconciles distributions per deployed pool (v3-51) |
| SCHED | kyc.scheduler.reconcile-sweep | SumSub webhook-loss reconciliation (every 5 min) |
| SCHED | kyc.scheduler.reverify-sweep | Re-KYC reminder window (v3-19) |
| SCHED | report.scheduler.fetch | Calls the partner report API for every authorized entity (whoami → fundIdsAuthorized / masterIdsAuthorized) and appends observations to report_events, recording each call in report_fetches — success and failure. Throws if any call failed, so the per-function alarm fires instead of the feed going quiet |
| SCHED | report.scheduler.derive | Rebuilds report_pool_latest from the log, then runs the NAV pricing sweep. An hour after the fetch, so a run derives what that run appended. Replaced fund-data.scheduler.sync (upsert-latest) and nav-proposals.scheduler.suggest |
| SCHED | activity-events.scheduler.archive | Retention / archive sweep for the audit log (v3-41) |
| SQS | notifications.worker.deliver | SES send worker — drains the delivery queue and writes SENT / FAILED per notification_deliveries row, retrying via SQS redelivery (1/5/15/60/240m → DLQ). Replaced the notification-logs.scheduler.send poller in v3-103 |
| SCHED | notifications.scheduler.sweep | Re-queues deliveries left PENDING (a lost SQS message or a worker that died mid-flight) |
| HOOK | notifications.webhook.ses | SES bounce / complaint events → BOUNCED / COMPLAINED, and adds the address to email_suppressions (HARD_BOUNCE / COMPLAINT) |
| WORKER | pools.worker.deploy | Async pool deploy — PlatformPoolFactory.createPool() (invoked by POST /pools) |
| WORKER | kyc.worker.apply-review | Async worker behind the SumSub webhook — applies the review result |
| WORKER | kyc.worker.mint-sbt | SQS consumer for the SBT on-chain FIFO queue (mint / burn / revoke) |