Stripe Connect Express Onboarding and KYC: Status Handling That Doesn't Stall Payouts
Stripe Connect Express onboarding with KYC, identity verification, and account.updated handling so connected accounts don't stall before first payout.
Creating a Stripe Connect account is the easy API call. Getting that account to charges_enabled and payouts_enabled — and keeping it there after KYC, capability requests, and the first restriction — is the actual product. Marketplace jobs that ask for Express onboarding, API-driven identity verification, and status handling are asking for this layer, not a Checkout session. This is how I build it, including the Connect work on the Dryva courier marketplace.
The rule that prevents most stall-outs: the return URL is not a success event. Stripe is the source of truth. Your database follows account.updated. If you mark a seller "onboarded" because they landed back on your site, you will pay out to accounts that are still restricted, or show a green check next to requirements.currently_due that is ten items long.
Express vs Custom vs Standard — pick before you write onboarding
Onboarding UX is downstream of account type. Change it later and you redo capabilities, hosted vs embedded flows, and what your platform is allowed to collect.
- Express — Stripe-hosted onboarding (Account Links or embedded components). You don't collect sensitive KYC yourself. Fastest path for most marketplaces. Sellers get an Express Dashboard for payouts and identity updates.
- Custom — you collect KYC (legal entity, owners, bank, identity documents) and pass it through the Accounts and Persons APIs. Use it when the onboarding UI must live entirely in your product and you can stomach the compliance surface.
- Standard — the connected account is a full Stripe account. Rarely what a marketplace wants; the seller can take their processing off your platform.
If a brief says "custom onboarding" it often means *your* branded flow that still uses Express Account Links, not Connect Custom accounts. Confirm which. Custom accounts are a different KYC and liability model. I default to Express unless the product genuinely cannot send the seller to Stripe's hosted or embedded onboarding.
Create the account with the capabilities you'll need on day one
Request card_payments and transfers (and whatever else your charge type needs) when you create the account. Adding a capability later reopens requirements and can freeze a seller who thought they were done. Controller properties replace the old type: "express" create in current API versions — match your Stripe SDK to the docs you're reading.
const account = await stripe.accounts.create({
country: seller.country, // ISO, set once — changing country later is painful
email: seller.email,
capabilities: {
card_payments: { requested: true },
transfers: { requested: true },
},
controller: {
stripe_dashboard: { type: "express" },
fees: { payer: "application" },
losses: { payments: "application" },
},
metadata: { seller_id: seller.id }, // your primary key, for webhook correlation
});
await db.sellers.update(seller.id, {
stripe_account_id: account.id,
onboarding_status: "pending",
});Persist stripe_account_id before you send anyone to onboarding. If the tab dies, you resume the same account — you do not create a second one and then debug why payouts hit a ghost connected account.
Account Links: return_url vs refresh_url
Account Links are short-lived URLs. return_url is "they clicked through or finished the hosted flow." refresh_url is "the link expired or they need a new one." Mixing them up is a classic stall: the seller hits an expired link, lands on a page that thinks they're done, and never gets a fresh session.
async function createOnboardingLink(accountId: string) {
const link = await stripe.accountLinks.create({
account: accountId,
type: "account_onboarding",
refresh_url: `${SITE}/connect/refresh`,
return_url: `${SITE}/connect/return`,
});
return link.url; // send the seller here; do not treat this as complete
}
// refresh_url handler: mint a new link and redirect. Never mark complete here.
export async function GET() {
const seller = await requireSeller();
const url = await createOnboardingLink(seller.stripe_account_id);
redirect(url);
}On return_url, retrieve the account (or wait for the webhook) and inspect charges_enabled, payouts_enabled, and requirements. Show "we still need a few details" when currently_due is non-empty. Show "under review" when Stripe is checking documents. Show "ready" only when both enabled flags are true and currently_due is empty. That UI is the product.
KYC status is a state machine, not a boolean
Identity verification on Express is Stripe's problem to collect; your problem is to mirror status so the seller and your ops team know why payouts haven't started. The fields that matter on the Account object:
requirements.currently_due— collect these or the account stays limited.requirements.past_due— overdue; capabilities may already be disabled.requirements.eventually_due— not blocking yet; don't ignore them or they become currently_due after volume or time.requirements.disabled_reason— why charges or payouts are off (requirements.past_due,rejected.fraud, listed, etc.).requirements.current_deadline— when currently_due becomes a disable.charges_enabled/payouts_enabled— the only flags that mean money can move.
Store a status your app understands: not_started, in_progress, restricted, enabled, disabled. Map from Stripe, don't invent a parallel KYC engine. When currently_due includes individual.verification.document or company owners, send the seller back through Account Links (type: "account_onboarding" still works for outstanding requirements) or an Account Management Link so they can finish identity verification. Do not email them a vague "complete your profile."
account.updated is the source of truth
Sellers close the tab. Stripe reviews documents asynchronously. Requirements appear after the first charge. None of that hits return_url. Subscribe to account.updated (and capability.updated if you request capabilities independently), verify the signature on the raw body, and upsert seller status from the payload. The same idempotency rules as any other Stripe webhook apply — I covered those in Stripe webhooks in Next.js.
// Inside your verified webhook handler
if (event.type === "account.updated") {
const account = event.data.object;
const due = account.requirements?.currently_due ?? [];
const status =
account.payouts_enabled && account.charges_enabled && due.length === 0
? "enabled"
: account.requirements?.disabled_reason
? "disabled"
: due.length
? "restricted"
: "in_progress";
await db.sellers.updateByStripeAccount(account.id, {
onboarding_status: status,
charges_enabled: account.charges_enabled,
payouts_enabled: account.payouts_enabled,
currently_due: due,
disabled_reason: account.requirements?.disabled_reason ?? null,
});
}
// Always 200 after a durable write so Stripe stops retrying.Correlate with metadata.seller_id or a unique stripe_account_id. If you can't find the seller, log and skip — do not create a new user from a webhook. That's how duplicate connected accounts start.
If onboarding complete is a column you set in a return-url route, it will lie. If it's a projection of charges_enabled, payouts_enabled, and currently_due, it will match Stripe.
After go-live: restrictions, not just first-time KYC
Connected accounts get new requirements when volume grows, when a person fails verification, or when Stripe's risk systems want more documents. Treat that as the same onboarding pipeline: webhook in, status update, prompt the seller with a fresh Account Link, don't silently queue payouts that will sit in pending or fail. Failed payouts and payout.failed belong next to this handler, not in a separate "we'll look at it later" bucket.
Charge type still matters once they're enabled — destination vs separate charges decides who eats a dispute. That's a different article: Stripe Connect platform fees. After that, the work is fraud, card testing, and safe payouts. Don't mix the designs. Onboarding gets them legal to transact; charge type decides how money moves; risk decides whether it should.
A checklist I use on Connect builds
- One connected account per seller, created before the first Account Link, with country and requested capabilities set.
- Account Links with distinct refresh and return URLs; refresh always mints a new link.
- Return URL reads Stripe (or waits for the webhook) — never writes
enabledon its own. account.updatedhandler is idempotent, raw-body verified, and mapscurrently_dueinto seller-facing copy.- Ops can see
disabled_reasonand outstanding requirements without opening Stripe Dashboard. - Post-launch restrictions reuse the same link + webhook path as first-time KYC.
Want this built into the marketplace, not just the article
I implement Express and Custom Connect onboarding — identity verification, capability requests, restricted-account recovery, and webhook-driven status — as part of Stripe Connect integration and marketplace development. That's the same work as split payouts and reconciliation; onboarding is where those integrations silently break. If you're scoping a marketplace or an existing flow is stalling before first payout, send me the details.