Skip to content

Fix: Stripe Connect Not Working — Account Onboarding, Charge Types, Application Fees, and Webhooks

FixDevs · (Updated: )

Quick Answer

How to fix Stripe Connect errors — Express vs Standard onboarding redirect, account_link expiration, direct vs destination charges, capability requirements, transfers and payouts, Connect webhook account.updated events.

The Error

You create a Connect Express account and the redirect link 404s:

The account link URL has expired or has been used.

Or a charge to a connected account fails with capability errors:

StripeInvalidRequestError: The connected account needs to have the 
card_payments and transfers capabilities enabled to process charges.

Or transfer_data.destination produces a different result than expected:

await stripe.paymentIntents.create({
  amount: 10000,
  currency: "usd",
  transfer_data: { destination: "acct_..." },
  application_fee_amount: 200,
});
// Funds in the wrong account, or fees not collected.

Or webhooks fire for the platform but not for connected accounts:

// In your webhook handler:
const event = stripe.webhooks.constructEvent(...);
// Only sees platform events, never connected-account events.

Why This Happens

Stripe Connect has three account types and three charge models. Most issues come from mismatching the two.

Account types:

  • Express — Stripe-hosted onboarding, partial control. Best for platforms that want fast onboarding without building their own KYC UI.
  • Standard — Connected user has a full Stripe Dashboard. Best when sellers want to control their account independently.
  • Custom — You build everything; users never see Stripe Dashboard. Required for tighter integrations.

Charge types:

  • Direct charges — Money flows directly to the connected account; platform takes an application fee.
  • Destination charges — Money flows to the platform, then transfers to the connected account.
  • Separate Charges and Transfers — Charge first, transfer later (different timing).

The right combo depends on your business model. Picking wrong leads to subtle issues like wrong fees, wrong receipts, wrong refund behavior.

The second source of confusion is Connect’s split between platform context and connected-account context. Every API call either runs as the platform or as a connected account, and the wrong context produces silently wrong results. Direct charges need the stripeAccount request option so the PaymentIntent lives on the seller’s books. Destination charges run on the platform without that option, because the platform is the merchant of record. Webhooks have the same split: events fire on either the platform stream or the Connect stream, with separate signing secrets, and subscribing on the wrong stream means you never see the event you expected.

The third surprise is capabilities. A connected account can exist, finish onboarding, and still fail to accept charges because a specific capability (card_payments, transfers, bank_transfer_payments) is pending or inactive. The platform-level charges_enabled: true flag is a summary; it does not guarantee the specific capability your charge requires for the country and payment method involved. Treat capability checks as part of your routing logic, not a one-time check at sign-up.

Fix 1: Create Express Accounts and Onboarding

import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

// 1. Create the account:
const account = await stripe.accounts.create({
  type: "express",
  country: "US",
  email: sellerEmail,
  capabilities: {
    card_payments: { requested: true },
    transfers: { requested: true },
  },
});

// 2. Generate an onboarding link (expires in ~10 minutes):
const accountLink = await stripe.accountLinks.create({
  account: account.id,
  refresh_url: "https://example.com/onboarding/refresh",
  return_url: "https://example.com/onboarding/return",
  type: "account_onboarding",
});

// 3. Redirect the seller to accountLink.url
res.redirect(accountLink.url);

The refresh_url is where Stripe sends the user if the link expires or they hit “Refresh.” The return_url is where they go after onboarding (success or otherwise — you must check the account status server-side).

In your /refresh handler:

app.get("/onboarding/refresh", async (req, res) => {
  const link = await stripe.accountLinks.create({
    account: req.user.stripeAccountId,
    refresh_url: "https://example.com/onboarding/refresh",
    return_url: "https://example.com/onboarding/return",
    type: "account_onboarding",
  });
  res.redirect(link.url);
});

In /return, check the account is ready:

app.get("/onboarding/return", async (req, res) => {
  const account = await stripe.accounts.retrieve(req.user.stripeAccountId);
  if (account.charges_enabled && account.payouts_enabled) {
    // Ready to accept payments.
    res.render("dashboard");
  } else {
    // Onboarding incomplete. Show what's missing:
    res.render("onboarding-incomplete", { account });
  }
});

Pro Tip: Never trust the return_url alone — users can bookmark and return without finishing. Always verify with stripe.accounts.retrieve server-side before granting access.

Fix 2: Direct Charges (Money to Seller’s Account)

Direct charges create the PaymentIntent on the connected account, not on the platform:

const paymentIntent = await stripe.paymentIntents.create({
  amount: 10000,
  currency: "usd",
  application_fee_amount: 200,  // $2.00 platform fee
}, {
  stripeAccount: connectedAccountId,  // Note: this is the second arg
});

The { stripeAccount } request option scopes the API call to the connected account. The charge appears on their statement; you receive application_fee_amount cents in your platform account.

Use direct charges when:

  • The seller is fully responsible for the transaction (refunds, customer support, taxes).
  • You want minimal involvement in disputes.
  • Your platform takes a clear % per transaction.

Common Mistake: Forgetting the stripeAccount option. Without it, the charge happens on your platform, and there’s no fee split — just a regular platform charge.

Fix 3: Destination Charges (Money to Platform, Transfer to Seller)

Destination charges create the PaymentIntent on the platform with a transfer destination:

const paymentIntent = await stripe.paymentIntents.create({
  amount: 10000,
  currency: "usd",
  transfer_data: {
    destination: connectedAccountId,
    amount: 9800,  // $98.00 goes to seller; $2.00 stays with platform
  },
});

Now:

  • The PaymentIntent is on your platform.
  • You’re the merchant of record on the receipt.
  • The platform handles disputes.
  • $98.00 transfers to the seller automatically when the charge succeeds.

For percentage-based fees instead of explicit amount:

const paymentIntent = await stripe.paymentIntents.create({
  amount: 10000,
  currency: "usd",
  application_fee_amount: 200,
  transfer_data: { destination: connectedAccountId },
});

application_fee_amount is what the platform keeps; the rest is transferred. (Easier to reason about than splitting amount and transfer_data.amount.)

Pro Tip: For SaaS with subscription markups, destination charges fit cleanly — the subscription is owned by the platform; you transfer the seller’s cut on each successful billing.

Fix 4: Required Capabilities

For US accounts to accept card payments and receive transfers:

await stripe.accounts.create({
  type: "express",
  capabilities: {
    card_payments: { requested: true },
    transfers: { requested: true },
  },
});

For non-US or other use cases, add more capabilities:

  • card_payments — accept card payments (always needed for direct charges).
  • transfers — receive transfers from the platform.
  • bank_transfer_payments — accept ACH/SEPA.
  • legacy_payments — older payment methods (consult docs).

Capabilities have lifecycle states: inactive, pending, active. They become active only after the seller completes verification.

To check current state:

const account = await stripe.accounts.retrieve(accountId);
console.log(account.capabilities.card_payments);  // "active" | "pending" | "inactive"

For verification UX, link to account.requirements.currently_due items — these are the fields Stripe needs from the user to enable capabilities.

Common Mistake: Requesting capabilities the country doesn’t support. Stripe ignores them; the capability stays inactive. Check the Connect supported countries matrix before requesting.

Fix 5: Webhooks for Connect Events

Connect has two webhook streams:

  • Platform webhooks — events for your platform’s account (regular Stripe events).
  • Connect webhooks — events for connected accounts (account.updated, etc.).

Configure two webhook endpoints in the Stripe Dashboard:

Endpoint 1: https://example.com/webhook/platform
   Events: payment_intent.succeeded, customer.subscription.created, ...
   Listen to: My platform's events (default)

Endpoint 2: https://example.com/webhook/connect
   Events: account.updated, account.application.deauthorized
   Listen to: Events on connected accounts

In your handler:

app.post("/webhook/connect", express.raw({ type: "application/json" }), (req, res) => {
  const event = stripe.webhooks.constructEvent(
    req.body,
    req.headers["stripe-signature"],
    process.env.STRIPE_CONNECT_WEBHOOK_SECRET,
  );

  if (event.type === "account.updated") {
    const account = event.data.object;
    // Update your DB with new capabilities, requirements, etc.
    syncAccountState(account);
  }

  res.json({ received: true });
});

The two endpoints have different signing secrets. Don’t mix them.

Common Mistake: Subscribing to account.updated on the platform endpoint. You’ll never receive them — they fire on the Connect channel.

Fix 6: Refunds and Reversals

For direct charges:

const refund = await stripe.refunds.create({
  payment_intent: paymentIntentId,
  refund_application_fee: true,  // Also refund the platform fee
  reverse_transfer: true,         // Reverse any transfer
}, { stripeAccount: connectedAccountId });

For destination charges:

const refund = await stripe.refunds.create({
  payment_intent: paymentIntentId,
  refund_application_fee: true,
  reverse_transfer: true,
});

refund_application_fee: true returns the platform fee. reverse_transfer: true pulls the seller’s portion back.

For partial refunds with split:

// Refund $5 from a $100 transaction with $5 fee:
const refund = await stripe.refunds.create({
  payment_intent: paymentIntentId,
  amount: 500,                           // $5 refund
  refund_application_fee: false,          // Platform keeps its $5 fee
  reverse_transfer: true,
});

For SaaS subscriptions, the platform owns the subscription — refund_application_fee and reverse_transfer are less common.

Fix 7: Test Mode With Connect

Test mode behaviors:

  • Express test accounts are pre-filled with fake but valid info — you can finish onboarding in seconds.
  • Test bank accounts (000123456789 for US) accept any number.
  • Use Stripe’s test cards — same numbers work in Connect.
  • Test webhooks via stripe listen --forward-to localhost:3000/webhook/platform and --connect flag for connected:
# Forward connected-account events:
stripe listen --forward-connect-to localhost:3000/webhook/connect

# Forward platform events:
stripe listen --forward-to localhost:3000/webhook/platform

To trigger test events:

stripe trigger account.updated --stripe-account acct_test_...

Pro Tip: Test the full onboarding flow in test mode end-to-end. The Express UX in test mode is identical to production — only the data is fake. Watch for typos in refresh_url and return_url.

Fix 8: Payouts

By default, Stripe pays out connected accounts on a schedule (daily, weekly, monthly). To customize:

await stripe.accounts.update(accountId, {
  settings: {
    payouts: {
      schedule: {
        interval: "weekly",
        weekly_anchor: "monday",
      },
    },
  },
});

For manual payouts (platform decides when):

await stripe.accounts.update(accountId, {
  settings: {
    payouts: {
      schedule: { interval: "manual" },
    },
  },
});

// Then explicitly trigger:
await stripe.payouts.create(
  { amount: 10000, currency: "usd" },
  { stripeAccount: accountId },
);

Manual payouts give you control but require you to manage timing. Useful for businesses that want to batch payouts or align with their accounting.

For instant payouts (faster, with a fee):

await stripe.payouts.create(
  { amount: 10000, currency: "usd", method: "instant" },
  { stripeAccount: accountId },
);

Stripe Connect vs Adyen, PayPal, and Square: Marketplace Payment Models

If you are picking a marketplace processor or evaluating whether to migrate off Connect, the trade-offs come down to onboarding control, fee economics, and global coverage. Stripe Connect is a developer-first platform with three account types and rich APIs. It optimizes for speed: you can have an Express seller onboarded and accepting cards in under five minutes, and the platform shoulders most of the KYC complexity. The cost is per-charge fees that match Stripe’s standard rate plus a Connect uplift, and a payout schedule that runs through Stripe’s banking rails.

Adyen for Platforms is the enterprise counterpart. It targets large marketplaces that need direct relationships with acquiring banks, granular interchange routing, and local payment methods (iDEAL, SEPA, BLIK) baked in across Europe and APAC. Adyen has higher fixed cost, longer integration time, and no equivalent of Stripe’s hosted Express onboarding, but it shines for marketplaces operating in 20+ countries with regulated KYC requirements. Choose it when local payment method coverage and merchant of record flexibility matter more than time to first charge.

PayPal Marketplaces (Hyperwallet for payouts plus the PayPal Commerce platform) is the right choice when your sellers expect PayPal balances and your buyers want PayPal as a payment option. The economics differ — PayPal charges per-transaction and per-payout — and the API surface is older and less consistent than Stripe’s. It wins where seller demographics demand PayPal balance settlement (gig platforms, international freelance marketplaces).

Square Marketplaces is constrained to the US, UK, and a few other markets, but it integrates cleanly with Square’s in-person POS hardware. If your platform has a physical retail component (e.g. a co-working space charging via shared POS), Square reduces the integration to one vendor instead of bolting Stripe Terminal onto Connect. For pure-digital marketplaces, Square’s reach is too narrow.

For most SaaS marketplaces — selling digital services, taking a percentage cut, paying out weekly — Stripe Connect is the path of least resistance. Pick a different vendor only when you hit a hard requirement (Adyen for European interchange optimization, PayPal for balance settlement, Square for in-person hardware) that Connect cannot match.

Still Not Working?

A few less-obvious failures:

  • account_link URL expired. They’re single-use and ~10 minutes. Don’t email them; generate on-demand when the user clicks.
  • platform_country_unsupported_currency. Your platform’s country can’t process charges in the requested currency. Use multi-currency Connect or restrict to supported currencies.
  • Connected account suddenly stops accepting charges. Capability moved from active to restricted_soon or restricted. Re-onboard via account_link with type: "account_update".
  • Application fees not appearing. They’re added to your platform’s balance, not the connected account’s. Check the Dashboard → Reports → Application Fees.
  • charges_enabled: true but charges fail. Check capabilities individually. The platform-level charges_enabled doesn’t guarantee the specific capability your charge needs.
  • OAuth (Standard) instead of Express. OAuth flow uses https://connect.stripe.com/oauth/v2/authorize and an authorization code exchange. Express skips this entirely. Pick one — don’t mix.
  • Webhook events out of order. Stripe doesn’t guarantee event order. For account.updated, the latest state is always available via stripe.accounts.retrieve — re-fetch on every webhook.
  • Tax in marketplaces. Stripe Tax for Connect varies by charge type and account type. The platform may or may not collect; sellers may or may not be liable. Read Stripe Tax for Connect docs carefully.
  • Idempotency keys missing on transfer retries. Connect transfers can be double-issued if your background job retries without Idempotency-Key. Add a stable key per logical transfer (e.g. transfer_${orderId}) and Stripe collapses duplicates server-side.
  • Cross-border payouts blocked by FX rules. A US platform paying out to a JP-based connected account hits cross-border fees and FX conversion that you may not have priced into your take rate. Check account.country before listing the seller and choose a charge type that minimizes the FX hop.
  • balance_insufficient on manual payouts. The connected account’s available balance is locked in pending transfers or disputed charges. Inspect balance.available[].amount and balance.pending[].amount separately before triggering a payout.

For related Stripe and payments issues, see Stripe integration not working, Stripe webhook signature verification failed, Better Auth not working, and CORS access-control-allow-origin.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles