Skip to content

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.

StageWhat it means
HeldThe 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.
SettledThe underlying card charge has cleared. A donation is settled once stripe_available_on <= NOW(). Only settled held funds are eligible to be released.
ReleasedA 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 outStripe 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:

StatusMeaning
heldNet funds still on the platform balance, not yet transferred.
pending_settlementDestination charge not yet settled (card clearing).
transferredA real Stripe transfer exists with a matching audit row.
directA destination charge that settled directly into the creator's account — no platform transfer needed.
anomalyThe 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

EndpointPurpose
GET /payouts/earningsThe 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-breakdownPer-donation attribution of the Pending tile, lazy-loaded by its dialog. Ledger-only, zero Stripe calls.
GET /stripe/accountsThe 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.

EndpointWhat it's for
POST /admin/users/{id}/release-held-fundsRelease 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}/releaseRelease a single held donation (per-row CTA). A failed Stripe transfer returns 200 with status: failed.
POST /admin/payouts/release-all-held-fundsBulk 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-settlementBackfill 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/reconcileReconcile held entries against Stripe transfer evidence, backfilling missing audit rows. Returns checked / reconciled / skipped / errors / details.
POST /admin/transfers/force-transferMark 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-flipsRecovery 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 one userId). Run this first and read off restoreCandidates / restoreCents.
  • execute — guarded ledger-only repair. Restores verified-stranded rows to held (the release machinery then transfers them properly), backfills missing audit rows where a transfer actually exists, and stamps stripe_account_id for confirmed destination charges. Requires expectedCandidates + expectedRestoreCents from the prior detect run; any drift returns 409.
  • attest — the operator personally vouches that money moved off-platform. Requires expectedCandidates + expectedRestoreCents and userId + reason (always per-creator, always reasoned).

Each candidate carries a classification:

ClassificationMeaning
restore_to_heldNo Stripe evidence money moved → safe to restore to held.
already_transferredA real tr_… exists; the audit row was just missing → backfill it.
direct_confirmedThe PI was a destination charge; the ledger row is legitimately non-held.
subscription_excludedSubscription rows are reported but never auto-restored.
skipped_concurrentThe entry changed between detect and execute.
held_unprovenStill held, no Stripe evidence — only operator attestation can resolve.
attestedThe operator attested the payout happened off-platform (attest mode).
errorStripe lookup failed — left untouched.

Built with VitePress