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 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 PaymentIntenttransfer to the talent's connected account (acct_xxx).

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

High-level flow

sequenceDiagram participant Talent as Talent (VerificationPage) participant App as zooly-app API participant Stripe as Stripe participant DB as Postgres Talent->>App: GET /payout-route App->>DB: getPayoutRouteByAccountId DB-->>App: { stripeConnectAccountId?, kycVerified } App-->>Talent: state → loading | needs-connect | needs-completion | connected Talent->>App: POST /api/payments/connect/start App->>Stripe: accounts.create (express) Stripe-->>App: acct_xxx App->>DB: upsertPayoutRoute({ acct_xxx, payoutMethod: STRIPE_CONNECT }) App->>Stripe: accountLinks.create (account_onboarding) Stripe-->>App: { url, expires_at } App-->>Talent: { url } Talent->>Stripe: window.location = url Note over Talent,Stripe: User completes hosted onboarding (or bails) Stripe->>App: GET /api/payments/connect/return App->>Stripe: accounts.retrieve(acct_xxx) Stripe-->>App: { charges_enabled, details_submitted, payouts_enabled } App->>DB: updateKycStatus(accountId, kycVerified) App-->>Talent: 302 → /dashboard/verification?status=ok|incomplete|error|missing Note over Stripe,DB: Async — Express verification can complete later Stripe-->>App: webhook account.updated App->>DB: refresh kycVerified

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).

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.

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.