Stripe Connect (Talent Payouts)

Express onboarding flow that lets talents receive offer payouts via Stripe Connect

Why Stripe Connect

Talents are paid out into their own bank accounts after a brand has paid for an offer and delivery is complete. Connect is payout onboarding. It is not required for talent Approve (accept) and not part of brand Send Payment (pay-in). We use Stripe Connect Express because Stripe hosts the onboarding UI and KYC checks — we don't have to collect SSNs, routing numbers, or run identity verification ourselves. Money still flows brand → platform PaymentIntent → later transfer to the talent's connected account (acct_xxx).

Shipped in ef054780 feat: Implement Stripe Connect onboarding for talent payouts.

High-level flow

Endpoints

POST /api/payments/connect/start

Auth: required (talent's user cookie).

  • Looks up the talent's account and any existing account_payout_routes row.
  • If stripeConnectAccountId is missing, creates a new Stripe Express account with capabilities transfers and card_payments. (We never charge cards on the connected account, but Stripe's default platform approval requires card_payments to be requested alongside transfers.) The account is persisted to account_payout_routes with payoutMethod = "STRIPE_CONNECT".
  • Creates a single-use AccountLink of type account_onboarding with:
    • return_url → ${NEXT_PUBLIC_APP_URL}/api/payments/connect/return
    • refresh_url → ${NEXT_PUBLIC_APP2_URL}/dashboard/verification?status=refresh
  • Returns { url }. The client redirects via window.location.href = url.

AccountLinks expire after ~5 minutes, so callers should always create a fresh one rather than caching the URL. Source: apps/zooly-app/app/api/payments/connect/start/route.ts.

GET /api/payments/connect/return

Stripe redirects here after the talent finishes (or abandons) hosted onboarding. The handler:

  1. Re-validates the auth cookie.
  2. Loads account_payout_routes.stripeConnectAccountId.
  3. Calls refreshKycFromStripe(), which reads the connected account and returns kycVerified = charges_enabled && details_submitted && payouts_enabled. Requiring all three flags prevents marking the talent verified before payouts are actually possible.
  4. Writes kycVerified to the DB only if it changed.
  5. Bounces the talent back to ${NEXT_PUBLIC_APP2_URL}/dashboard/verification?status=<status>.

Possible status values:

ValueMeaning
okKYC verified — green "Account Connected" state
incompleteStripe still needs more info — UI shows "Continue setup"
errorServer-side error reading the account — UI shows red banner
missingUser landed on /return without ever calling /start (no acct_xxx row) — UI sends them back to the Connect button
refreshStripe asked the user to retry from refresh_url — UI re-fetches state

Source: apps/zooly-app/app/api/payments/connect/return/route.ts.

account.updated webhook

The /return redirect is best-effort: Express verification can complete asynchronously, so kycVerified may still be false on the immediate return. The account.updated webhook is the source of truth.

  • Located in packages/srv-stripe-payment/src/webhook.ts (case "account.updated").
  • Looks up the payout route by stripeConnectAccountId. If unknown (e.g. an account from another platform), logs and ignores.
  • Recomputes kycVerified from charges_enabled && details_submitted && payouts_enabled.
  • Writes only if the value changed (avoids redundant writes on noisy Stripe events).

Staged payouts and Connect

Since staged payouts, an offer agreed with a schedule pays the talent in parts. Connect works the same way for each part: money goes out only to a talent whose account is ready.

  • Once a deal is agreed, the first time its parts are checked (the brand pays, the schedule is saved, or the daily check runs), payees who cannot receive money yet get one notice: "Connect Stripe to get paid". It points to Settings › Payouts & verification, where the Connect button already lives. It is sent once, not on every stage.
  • A stage due before Connect is ready waits in a waiting for Connect state. It goes out by itself once verification completes, including when the webhook is the one that learns it. The daily check also re-reads Stripe for anyone still waiting, so a missed webhook does not strand the money.
  • The brand is never blocked on the talent's Connect. A brand can accept delivery and complete a scheduled deal; the money waits instead of the deal stalling.
  • Every release and every failure is written on the deal timeline, once each. A release sends each payee a "Payout released" notice, emailed as "A payout is on its way".

See Staged payouts for the schedule itself.

Helpers (packages/srv-stripe-payment/src/connect.ts)

FunctionPurpose
createConnectedAccount({ email, country, metadata })stripe.accounts.create({ type: "express", country: "US", capabilities: { transfers, card_payments } }). Metadata always includes accountId and userId so the webhook can join back to our DB.
createAccountLink({ stripeConnectAccountId, refreshUrl, returnUrl })stripe.accountLinks.create({ type: "account_onboarding" }). One-time URL, ~5-minute TTL.
refreshKycFromStripe(stripeConnectAccountId)stripe.accounts.retrieve(...) → returns { account, kycVerified } with kycVerified = charges_enabled && details_submitted && payouts_enabled.

DB shape

account_payout_routes (see packages/db/src/access/accountPayoutRoutes.ts):

ColumnTypeDescription
account_idtext (FK → account.id)One row per talent account
payout_methodtext enum"STRIPE_CONNECT" is the only value today
stripe_connect_account_idtextStripe acct_xxx (set on first /start)
kyc_verifiedbooleanMirror of Stripe's verification state — written by /return and the webhook

The talent SPA reads this row via GET /payout-route and renders one of four UI states (loading, needs-connect, needs-completion, connected).

Environment

VarUsed for
STRIPE_SECRET_KEYServer-side Stripe SDK
STRIPE_WEBHOOK_SECRETVerifying account.updated webhook signatures
NEXT_PUBLIC_APP_URLBuilding the absolute return_url for AccountLink
NEXT_PUBLIC_APP2_URLBuilding the absolute refresh_url and the redirect back to the talent SPA

Manual test (Stripe test mode)

#StepExpected
1Sign in as a fresh test talent. Open Verification.Black Connect Account button visible. No green badge.
2Click Connect Account.Browser redirects to connect.stripe.com/setup/....
3Fill the Stripe form with test data: SSN 000-00-0000, routing 110000000, account 000123456789. Submit.Lands back on /dashboard/verification?status=ok. Green "Account Connected" badge renders.
4Refresh the page.Green badge persists (proves persistence, not client-only state).
5Re-run the flow but close the Stripe tab before submitting.Button text becomes Continue setup; banner reads "Stripe still needs more info".
6Inspect Postgres: select * from account_payout_routes where account_id = ....One row with stripe_connect_account_id = acct_xxx, payout_method = STRIPE_CONNECT, kyc_verified = true after step 3.

Get paid card and payout gate (Oct 2026)

Talent and agencies see a Get paid card at the top of Settings and on the Verification page. It shows where they are with Stripe: not connected, setup not finished, Stripe is verifying, Stripe needs more, or ready. Once they have started, it also shows three checks: details submitted, charges enabled, payouts enabled. Connect Stripe (or Continue setup) opens Stripe's hosted onboarding. When Stripe sends them back, the page asks Stripe for the latest status before showing it, and Refresh status does the same on demand.

Ready means Stripe has the details, charges and payouts are on, and the account can receive transfers. Until then, money from a deal stays in Zooly's Stripe balance. Staged payouts wait and release by themselves when Stripe confirms the account. An admin cannot force a release to someone who is not ready: the payouts admin page says in plain words who is not connected and why.

The payouts admin page shows each payee's Stripe status with the same checks, what Stripe still needs, when it was last checked, and a Refresh from Stripe button. Nobody can type in a Stripe account by hand; the only way to attach one is the Get paid onboarding.

Known follow-ons

  • We do not yet expose a "disconnect / re-link" UI — talents who need to switch bank accounts have to go through Stripe's hosted account dashboard.
  • Country is hard-coded to US in createConnectedAccount. Multi-country support is out of scope here.
  • Manual payouts (offline payment routes) are a separate payoutMethod enum value but are not yet wired in the UI.