Skip to content

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

FixDevs ·

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.

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 },
);

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.

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