consulting hosting industries daily about contact

Stripe Connect: Don't lose sellers in the KYC maze

Stripe Connect's hosted onboarding sounds turnkey until you actually implement it. Here's what I've learned shipping it for real marketplaces.

Stripe Connect: Don't lose sellers in the KYC maze

Stripe Connect's hosted onboarding is genuinely good — until it isn't. The part Stripe doesn't put on the landing page is that KYC friction will kill your seller conversion rate if you don't architect the flow carefully. I've shipped this for a few marketplace clients now and the pattern that works is nothing like the quickstart.

What problem Connect actually solves

If you're building a marketplace — anything where money flows from buyer through your platform to a third-party seller — you have two options: become a payment facilitator yourself (months of compliance work, real legal exposure, underwriting relationships) or use something like Stripe Connect to offload that to Stripe.

Connect handles the actual money movement, the 1099-K reporting, the identity verification, the bank account validation. Your platform sits in the middle, takes a cut, and stays out of the money transmission business legally. That's the deal.

The three Connect account types are Standard, Express, and Custom. Standard gives Stripe full control of the dashboard and the onboarding UX. Custom gives you full control and full responsibility. Express is the middle ground — Stripe hosts the onboarding flow and the dashboard, but it feels like your brand. For most of my clients, Express is the right call. I'll focus there.

The flow, done right

The naive implementation looks like this: user signs up as a seller, you immediately create a Connect account and redirect them to Stripe's hosted onboarding. They hit the KYC wall, get scared, abandon.

The flow that actually converts:

  1. Seller signs up, creates their profile, lists their first product — do all of that inside your app before touching Stripe at all.
  2. When they try to do something that requires payouts (withdraw earnings, hit a payout threshold), then you create the Connect account and send them through onboarding.
  3. You store their requirements state and surface friendly reminders from your side, not just relying on Stripe's emails.

Here's how I create the Express account and generate the onboarding link in Laravel:

use Stripe\StripeClient;

class StripeConnectService
{
    protected StripeClient $stripe;

    public function __construct()
    {
        $this->stripe = new StripeClient(config('services.stripe.secret'));
    }

    public function createExpressAccount(User $user): string
    {
        $account = $this->stripe->accounts->create([
            'type'         => 'express',
            'country'      => 'US',
            'email'        => $user->email,
            'capabilities' => [
                'transfers' => ['requested' => true],
            ],
            'metadata' => [
                'user_id' => $user->id,
            ],
        ]);

        $user->update(['stripe_connect_id' => $account->id]);

        return $account->id;
    }

    public function createOnboardingLink(User $user): string
    {
        if (! $user->stripe_connect_id) {
            $this->createExpressAccount($user);
        }

        $link = $this->stripe->accountLinks->create([
            'account'     => $user->stripe_connect_id,
            'refresh_url' => route('seller.connect.refresh'),
            'return_url'  => route('seller.connect.return'),
            'type'        => 'account_onboarding',
        ]);

        return $link->url;
    }

    public function getAccountStatus(User $user): array
    {
        if (! $user->stripe_connect_id) {
            return ['status' => 'not_started'];
        }

        $account = $this->stripe->accounts->retrieve($user->stripe_connect_id);

        return [
            'status'              => $account->details_submitted ? 'submitted' : 'incomplete',
            'charges_enabled'     => $account->charges_enabled,
            'payouts_enabled'     => $account->payouts_enabled,
            'requirements'        => $account->requirements->currently_due,
            'disabled_reason'     => $account->requirements->disabled_reason,
        ];
    }
}

And the controller pair for the return/refresh URLs:

class SellerConnectController extends Controller
{
    public function __construct(protected StripeConnectService $connectService) {}

    public function redirect(Request $request): RedirectResponse
    {
        $url = $this->connectService->createOnboardingLink($request->user());
        return redirect()->away($url);
    }

    // Stripe calls this if the link expires or the user clicks back
    public function refresh(Request $request): RedirectResponse
    {
        // Generate a fresh link — the old one is single-use and expires in 24h
        $url = $this->connectService->createOnboardingLink($request->user());
        return redirect()->away($url);
    }

    // Stripe calls this when onboarding flow ends — success OR abandonment
    public function return(Request $request): View
    {
        $status = $this->connectService->getAccountStatus($request->user());

        return view('seller.connect.return', compact('status'));
    }
}

Critical note on that return_url: Stripe redirects here whether the seller completed onboarding or just closed the tab. Do not show a "you're all set!" message on this page without actually checking the account status. I have seen this mistake in production code from other shops. Check payouts_enabled before you celebrate.

The gotchas that cost me time

Account links are single-use and expire in 24 hours. If a seller bookmarks the Stripe onboarding URL and comes back tomorrow, it's dead. Your refresh_url exists exactly for this — Stripe will redirect there when the link is expired. I always implement refresh as a fresh link generation, never a static page.

details_submitted is not the same as payouts_enabled. A seller can submit all their details and still have payouts disabled because Stripe's risk team has a question, or a document failed verification, or they're in a restricted industry category. I check payouts_enabled for "can this person actually receive money" and check requirements.currently_due to know what's blocking them.

The requirements.eventually_due list will grow over time. Stripe does progressive KYC. A seller who onboards cleanly today might get a new requirement added six months from now — additional verification because their payout volume crossed a threshold, for example. I listen to the account.updated webhook and re-surface the onboarding link in the dashboard when new requirements appear. If you don't do this, sellers go into a quiet payout-disabled state and you get a support ticket instead.

Non-US sellers are a different world. The capabilities available, the required fields, the document types — they vary significantly by country. I had a client who launched in Canada assuming it would be "basically the same" as US onboarding. It wasn't. Test with actual country-specific test accounts in Stripe's dashboard before you promise your client a ship date.

Express accounts can't be deleted, only rejected. If a user closes their seller account on your platform, you can deauthorize the Connect account from your platform's access, but the underlying Stripe account exists forever from Stripe's perspective. This matters for GDPR/data deletion requests. Know this before you promise users you can wipe their data.

Webhooks you actually need to handle

Don't poll the account status. Subscribe to these events on your Connect webhook endpoint (separate from your main Stripe webhook):

account.updated          — requirements changed, payouts enabled/disabled
account.application.deauthorized — seller disconnected your platform
payout.paid              — useful for seller dashboards
payout.failed            — you need to know this immediately

The account.updated event is the important one. When payouts_enabled flips to true, that's when I mark the seller as "fully onboarded" in my database and send them a congrats email. Not on the return URL redirect.

When I'd reach for this (and when I wouldn't)

I'd use Stripe Connect Express for any marketplace where:

  • Sellers are individuals or small businesses in supported countries
  • You want to avoid building and maintaining a payout dashboard
  • 1099-K compliance matters and you want Stripe to own it
  • Your platform cut is straightforward (flat percent or fixed fee per transaction)

I wouldn't use it if:

  • You need highly customized KYC flows (go Custom, but budget real time for it)
  • Your sellers are all enterprise companies with NET-30 invoicing — Connect isn't designed for that, you want something like Stripe invoicing or a direct ACH setup
  • Your marketplace is in a high-risk vertical that Stripe doesn't love — check their restricted businesses list early, not after you've built the whole thing

For a Seattle e-commerce client I worked with last year, Express was the right call. Most of their sellers were individuals and small shops. We deferred onboarding until first payout attempt, built the webhook handling for requirements changes, and ended up with seller conversion through KYC in the high sixties — which is honestly pretty good for a flow that involves uploading a government ID.

Bottom line

Stripe Connect does the hard parts of marketplace payments well enough that I keep reaching for it. But the onboarding UX is only as good as the scaffolding you build around it — defer the KYC ask, handle the webhooks properly, and never trust the return URL as a completion signal. Get those three things right and you'll stop losing sellers to a form they didn't understand.

Need help shipping something like this? Get in touch.