Express onboarding flow that lets talents receive offer payouts via Stripe Connect
Talents are paid out into their own bank accounts after a brand pays for and completes an offer. 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 flows brand → platform PaymentIntent → 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.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. |
US in createConnectedAccount. Multi-country support is out of scope here.payoutMethod enum value but are not yet wired in the UI.