Express onboarding flow that lets talents receive offer payouts via 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.
POST /api/payments/connect/startAuth: required (talent's user cookie).
account and any existing account_payout_routes row.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".AccountLink of type account_onboarding with:
return_url → ${NEXT_PUBLIC_APP_URL}/api/payments/connect/returnrefresh_url → ${NEXT_PUBLIC_APP2_URL}/dashboard/verification?status=refresh{ 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/returnStripe redirects here after the talent finishes (or abandons) hosted onboarding. The handler:
account_payout_routes.stripeConnectAccountId.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.kycVerified to the DB only if it changed.${NEXT_PUBLIC_APP2_URL}/dashboard/verification?status=<status>.Possible status values:
| Value | Meaning |
|---|---|
ok | KYC verified — green "Account Connected" state |
incomplete | Stripe still needs more info — UI shows "Continue setup" |
error | Server-side error reading the account — UI shows red banner |
missing | User landed on /return without ever calling /start (no acct_xxx row) — UI sends them back to the Connect button |
refresh | Stripe 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 webhookThe /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.
packages/srv-stripe-payment/src/webhook.ts (case "account.updated").stripeConnectAccountId. If unknown (e.g. an account from another platform), logs and ignores.kycVerified from charges_enabled && details_submitted && payouts_enabled.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.
See Staged payouts for the schedule itself.
packages/srv-stripe-payment/src/connect.ts)| Function | Purpose |
|---|---|
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. |
account_payout_routes (see packages/db/src/access/accountPayoutRoutes.ts):
| Column | Type | Description |
|---|---|---|
account_id | text (FK → account.id) | One row per talent account |
payout_method | text enum | "STRIPE_CONNECT" is the only value today |
stripe_connect_account_id | text | Stripe acct_xxx (set on first /start) |
kyc_verified | boolean | Mirror 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).
| Var | Used for |
|---|---|
STRIPE_SECRET_KEY | Server-side Stripe SDK |
STRIPE_WEBHOOK_SECRET | Verifying account.updated webhook signatures |
NEXT_PUBLIC_APP_URL | Building the absolute return_url for AccountLink |
NEXT_PUBLIC_APP2_URL | Building the absolute refresh_url and the redirect back to the talent SPA |
| # | Step | Expected |
|---|---|---|
| 1 | Sign in as a fresh test talent. Open Verification. | Black Connect Account button visible. No green badge. |
| 2 | Click Connect Account. | Browser redirects to connect.stripe.com/setup/.... |
| 3 | Fill 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. |
| 4 | Refresh the page. | Green badge persists (proves persistence, not client-only state). |
| 5 | Re-run the flow but close the Stripe tab before submitting. | Button text becomes Continue setup; banner reads "Stripe still needs more info". |
| 6 | Inspect 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. |
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.
US in createConnectedAccount. Multi-country support is out of scope here.payoutMethod enum value but are not yet wired in the UI.