Payouts & the Money Lifecycle
This page explains how donation money moves through FundlyHub — from the moment a donation is captured to the moment it lands in a creator's bank account — and what each admin recovery endpoint is for. All money figures in the API are integers of cents unless a field name ends in a plain dollar unit (the creator-facing earnings tiles return dollars).
The lifecycle: held → settled → released → paid out
A paid donation passes through a sequence of states. The platform's ledger (ledger_entries) is the source of truth; Stripe is the custodian of the actual money.
| Stage | What it means |
|---|---|
| Held | The net amount sits on FundlyHub's platform balance as a creator_payable / held ledger row. This happens when the donation was captured before the creator finished Stripe Connect onboarding — Stripe's own balance knows nothing about it. |
| Settled | The underlying card charge has cleared. A donation is settled once stripe_available_on <= NOW(). Only settled held funds are eligible to be released. |
| Released | A Stripe transfer moves the settled net amount from the platform balance into the creator's connected account. A transfer audit row is written and the ledger row leaves the held bucket. |
| Paid out | Stripe pays the creator's connected-account balance out to their external bank, either on their automatic schedule or via a manual payout. From the creator's perspective the money has "left Stripe" once a payout is paid or in_transit. |
For creators who onboarded before receiving donations, money is captured directly as a Stripe destination charge — it never enters the held bucket and shows up in the creator's Stripe balance immediately (subject to the usual card-settlement delay).
Evidence-based transfer statuses
The admin Transfers grid (GET /admin/payouts/transfers) derives each row's status from ledger + Stripe evidence rather than a mutable status column, so a row's badge can never disagree with the filter. The statuses:
| Status | Meaning |
|---|---|
held | Net funds still on the platform balance, not yet transferred. |
pending_settlement | Destination charge not yet settled (card clearing). |
transferred | A real Stripe transfer exists with a matching audit row. |
direct | A destination charge that settled directly into the creator's account — no platform transfer needed. |
anomaly | The evidence is contradictory or incomplete; status genuinely unknown until remediation. Admin surface only — never shown to creators. |
The KPI tiles on the Payout Management page (GET /admin/payouts/stats) are computed from the same evidence model, so the tiles always agree with the grid.
Creator-facing earnings
| Endpoint | Purpose |
|---|---|
GET /payouts/earnings | The five Earnings-tab tiles (total, pending, available, withdrawn, fees) for the authenticated creator. pending includes platform-held net funds that Stripe's balance doesn't know about. |
GET /payouts/earnings/pending-breakdown | Per-donation attribution of the Pending tile, lazy-loaded by its dialog. Ledger-only, zero Stripe calls. |
GET /stripe/accounts | The creator's Stripe Connect account(s) and onboarding flags. |
These endpoints always derive the target from the authenticated principal — a ?userId query param is ignored (#1072).
Admin recovery endpoints
These exist because the happy path (webhook-driven release on account.updated / balance.available) can miss a fire, and because a class of bugs (#1126 / #1131) once flipped ledger rows out of held without evidence money actually moved. Every money-movement endpoint writes an audit log. None of the remediation endpoints ever call stripe.transfers.create — real money movement stays in the release machinery.
| Endpoint | What it's for |
|---|---|
POST /admin/users/{id}/release-held-funds | Release all of one creator's settled held funds. The operator must assert expectedAmountCents; a mismatch with the computed settled-held SUM returns 409 with the per-fundraiser breakdown and no money moves. |
POST /admin/payouts/donations/{donationId}/release | Release a single held donation (per-row CTA). A failed Stripe transfer returns 200 with status: failed. |
POST /admin/payouts/release-all-held-funds | Bulk walk every creator with held entries and release for those with an active connected account. No amount guard — same trust profile as the webhook. |
POST /admin/payouts/backfill-settlement | Backfill stripe_available_on for old held donations that predate the settlement webhooks, so they stop showing "Pending settlement" forever. Also clears contradictory stripe_account_id stamps. Reports stampsCleared. |
POST /admin/transfers/reconcile | Reconcile held entries against Stripe transfer evidence, backfilling missing audit rows. Returns checked / reconciled / skipped / errors / details. |
POST /admin/transfers/force-transfer | Mark specific held entries transferred against named Stripe transfer evidence. Requires { items: [{ donationId, stripeTransferId }], reason }; each item is verified with stripe.transfers.retrieve first. Evidence-free platform-wide flips are no longer supported (#1131). |
POST /admin/transfers/remediate-false-flips | Recovery for false "transferred" flips (#1126). See modes below. |
Remediating false transfer flips
POST /admin/transfers/remediate-false-flips has three modes:
detect— read-only classification of candidate rows (optionally scoped to oneuserId). Run this first and read offrestoreCandidates/restoreCents.execute— guarded ledger-only repair. Restores verified-stranded rows toheld(the release machinery then transfers them properly), backfills missing audit rows where a transfer actually exists, and stampsstripe_account_idfor confirmed destination charges. RequiresexpectedCandidates+expectedRestoreCentsfrom the prior detect run; any drift returns 409.attest— the operator personally vouches that money moved off-platform. RequiresexpectedCandidates+expectedRestoreCentsanduserId+reason(always per-creator, always reasoned).
Each candidate carries a classification:
| Classification | Meaning |
|---|---|
restore_to_held | No Stripe evidence money moved → safe to restore to held. |
already_transferred | A real tr_… exists; the audit row was just missing → backfill it. |
direct_confirmed | The PI was a destination charge; the ledger row is legitimately non-held. |
subscription_excluded | Subscription rows are reported but never auto-restored. |
skipped_concurrent | The entry changed between detect and execute. |
held_unproven | Still held, no Stripe evidence — only operator attestation can resolve. |
attested | The operator attested the payout happened off-platform (attest mode). |
error | Stripe lookup failed — left untouched. |