A finance and engineering team member look stressed at monitors, grappling with an unexpected stripe connect platform dispute liability flow.
Production engineering patternUpdated

Stripe Connect Dispute Liability: Who Pays When a Buyer Charges Back

How dispute liability actually flows across Standard, Express, and Custom Connect accounts — webhook by webhook, balance by balance, with the vendor-went-negative case mapped end-to-end.

The problem

The conversation that exposes this bug usually starts in a Slack channel between engineering and finance. A vendor on the platform got a chargeback. The vendor's payout went out two days ago at the full amount. The chargeback hit the platform's Stripe balance this morning. Finance wants to know how that money comes back from the vendor, and engineering has to admit the answer is: it does not, unless we go reverse the transfer manually, and we have not built that yet.

Liability is a matrix, not a rule

The root issue is that Stripe Connect dispute liability is not one rule. It is a matrix of rules that depend on the account type (Standard, Express, or Custom), the charge type (direct, destination, or separate charges and transfers), and whether the platform has chosen to take on negative-balance responsibility for that connected account. Teams that build their Connect integration without mapping that matrix end up surprised the first time a dispute lands. The surprise is typically the same shape: the platform balance is debited, the vendor balance is not, and the code path to claw the money back from the vendor does not exist.

Platform balance drain on every chargeback

The downstream business impact is uglier than most teams expect. A single $1,000 chargeback on a destination charge debits $1,000 plus the dispute fee from the platform's Stripe balance instantly. If the vendor's payout already cleared, the platform is now out the full amount with no automatic recovery. Multiply that by even a low dispute rate across a marketplace of a few hundred vendors and the loss column on the monthly close grows fast.

Response-window deadlines no human is tracking

Worse, the response window is short: Stripe documents it as usually 7 to 21 days depending on the card network, and a missed deadline means automatic loss with no appeal. Teams that find out about disputes by checking the Dashboard on Monday morning have already lost most of their fightable cases.

The pattern below maps the liability matrix end to end, names every webhook that fires across the lifecycle of a dispute, and shows the code shape for handling each one cleanly. It also covers the recovery path for the vendor-went-negative case, because that is the one that quietly drains the platform balance sheet if you do not have it wired up before the first dispute.

Engineers collaborate at a whiteboard, mapping out the complex stripe connect platform dispute liability flow architecture.

What changes for your business

The architecture has four parts that have to be designed together: a liability map keyed on account-type plus charge-type, a webhook handler set that covers the full dispute lifecycle, an evidence submission pipeline that respects the card-network response window, and a vendor recovery worker that runs transfer reversals before the 180-day platform-reserve transfer fires.

Liability map in code, not in a wiki

Start with the liability map. Stripe's docs are explicit that for Standard accounts the connected account is liable for direct charges while the platform is liable for destination charges. For Express and Custom accounts the platform is typically responsible for handling disputes and refunds, with Express offering an opt-in to delegate that to the connected account through the Express Dashboard. The single rule that covers all destination charges and separate charges and transfers, regardless of account type, is that Stripe debits the dispute amount and the dispute fee from the platform account first.

| Account Type | Direct Charges | Destination Charges | Separate Charges + Transfers | |---|---|---|---| | Standard | Connected account liable | Platform debited first | Platform debited first | | Express | Platform liable (delegatable) | Platform debited first | Platform debited first | | Custom | Platform liable | Platform debited first | Platform debited first |

That table lives in your code as a function, not as a comment. Every place that creates a charge or handles a dispute consults it. The function takes the account type and the charge type and returns the liable party plus whether the platform balance gets debited at dispute time:

type AccountType = "standard" | "express" | "custom";
type ChargeType = "direct" | "destination" | "separate_charges_and_transfers";

interface LiabilityRule {
  liable_party: "platform" | "connected_account";
  platform_balance_debited_first: boolean;
  recovery_via_transfer_reversal: boolean;
}

function getLiabilityRule(
  accountType: AccountType,
  chargeType: ChargeType,
): LiabilityRule {
  if (accountType === "standard" && chargeType === "direct") {
    return {
      liable_party: "connected_account",
      platform_balance_debited_first: false,
      recovery_via_transfer_reversal: false,
    };
  }
  // Every other combination debits the platform first and requires
  // a transfer reversal to recover from the connected account.
  return {
    liable_party: "platform",
    platform_balance_debited_first: true,
    recovery_via_transfer_reversal: chargeType !== "direct",
  };
}

Webhook handler set across the lifecycle

Once that map is in code, the webhook handler set is straightforward. Stripe fires five dispute-related events, and a Connect platform needs handlers for at least three of them. charge.dispute.created is the legal event — the buyer has opened a dispute and the response clock is now running. charge.dispute.funds_withdrawn is the cash event — Stripe has pulled the money out of your account. charge.dispute.closed is the resolution event — the dispute ended in won, lost, or warning_closed. charge.dispute.updated and charge.dispute.funds_reinstated are useful for tracking but not strictly required.

The handler for charge.dispute.created does three things: it persists the dispute with a link to the original charge and the original transfer, it kicks off the evidence-gathering workflow with a deadline derived from the card network, and it determines whether the platform or the vendor should be notified to assemble the evidence. The lookup from charge to transfer is the one that needs to be fast and offline, because you will hit it on every dispute and you do not want to be making API calls under time pressure.

async function handleDisputeCreated(
  dispute: Stripe.Dispute,
  tx: DatabaseTransaction,
): Promise<void> {
  const charge = await tx.charges.findByStripeId(dispute.charge as string);
  if (!charge) {
    logger.error({ dispute_id: dispute.id }, "dispute on unknown charge");
    return;
  }

  const rule = getLiabilityRule(charge.account_type, charge.charge_type);

  await tx.disputes.insert({
    stripe_dispute_id: dispute.id,
    charge_id: charge.id,
    transfer_id: charge.transfer_id, // cached at charge-creation time
    connected_account_id: charge.connected_account_id,
    amount: dispute.amount,
    currency: dispute.currency,
    reason: dispute.reason,
    status: dispute.status,
    evidence_due_by: new Date(dispute.evidence_details.due_by * 1000),
    liable_party: rule.liable_party,
    requires_transfer_reversal: rule.recovery_via_transfer_reversal,
    state: "evidence_pending",
  });

  if (rule.liable_party === "platform") {
    await enqueueEvidenceWorkflow(dispute.id, charge);
  } else {
    await notifyConnectedAccountOfDispute(charge.connected_account_id, dispute);
  }
}

Funds-withdrawn as the deferred recovery trigger

The charge.dispute.funds_withdrawn handler is where the recovery worker gets kicked off. On a destination charge or a separate-charges-and-transfers flow, this is the moment the platform balance loses money. The handler records the cash impact in the platform's general ledger and, if the liability rule says the vendor should ultimately carry the loss, schedules a transfer reversal against the original transfer. The reversal is scheduled, not run immediately, because you generally want to wait until the dispute is closed before clawing the money back — winning the dispute reinstates the funds, and a premature reversal creates a vendor-balance hole that you then have to refund.

async function handleFundsWithdrawn(
  dispute: Stripe.Dispute,
  tx: DatabaseTransaction,
): Promise<void> {
  const record = await tx.disputes.findByStripeId(dispute.id);
  if (!record) return;

  await tx.disputes.update(record.id, {
    state: "funds_withdrawn",
    funds_withdrawn_at: new Date(),
  });

  await tx.ledger.insert({
    account: "platform_dispute_holding",
    direction: "debit",
    amount: dispute.amount,
    currency: dispute.currency,
    reference_type: "stripe_dispute",
    reference_id: dispute.id,
    memo: `Funds withdrawn for dispute ${dispute.id} on charge ${dispute.charge}`,
  });

  // Recovery is deferred to dispute close — we do not reverse the
  // transfer yet because winning the dispute reinstates the funds.
}

Dispute-closed branch on won vs lost

The charge.dispute.closed handler is where the dispute resolves into either a loss or a reinstatement. If the dispute is won or warning_closed, charge.dispute.funds_reinstated will fire and the platform balance gets the money back; the handler updates the case status and reverses the ledger entry. If the dispute is lost, the funds are permanently gone from the platform balance, and this is the moment the recovery worker runs. For account-type-plus-charge-type combinations where the rule says to recover from the vendor, the handler creates a transfer reversal against the original transfer ID. If the vendor's balance does not have enough to cover the reversal, Stripe will let the account go negative — and from there the negative-balance recovery flow takes over.

async function handleDisputeClosed(
  dispute: Stripe.Dispute,
  tx: DatabaseTransaction,
): Promise<void> {
  const record = await tx.disputes.findByStripeId(dispute.id);
  if (!record) return;

  await tx.disputes.update(record.id, {
    state: dispute.status, // "won" | "lost" | "warning_closed"
    closed_at: new Date(),
  });

  if (dispute.status === "lost" && record.requires_transfer_reversal) {
    await scheduleTransferReversal({
      transfer_id: record.transfer_id,
      amount: dispute.amount,
      description: `Recovery for lost dispute ${dispute.id}`,
    });
  }
}

Evidence pipeline with one-shot submission

The evidence submission pipeline is where most teams cut corners and then regret it. Stripe gives you one submission per dispute — there is no way to add files after the initial response — so the pipeline needs to gather everything before it submits. The deadline is read from evidence_details.due_by on the dispute object, and the pipeline needs to enforce a soft deadline at 24 to 48 hours before that hard deadline so a human has time to review. Mastercard caps the combined evidence file at 19 pages, so the assembler needs format-aware truncation, not just byte-count truncation. A pipeline that submits a 21-page PDF to Mastercard will silently fail validation and burn the only submission you get.

Vendor recovery worker and the negative-balance path

For the vendor recovery path, the transfer reversal API is the only mechanism that moves money from the connected account back to the platform balance after a destination charge dispute. The reversal can be partial or full, and it carries the same currency as the original transfer. The worker that runs the reversal needs to handle the case where the vendor's balance is insufficient — Stripe will let the account go negative, and the negative-balance recovery flow then runs against the vendor's external bank account (where the region supports automatic debit) before the 180-day platform-reserve transfer fires. Building the worker to log every step of that chain — reversal attempted, vendor balance before and after, external account debit attempted, residual negative balance — is what gives finance the audit trail they need to write off the loss correctly when it does land.

A confident founder reviews a clear dashboard, demonstrating full control over the stripe connect platform dispute liability flow.

More on this

Common failure modes

The first failure mode is treating the dispute-liability map as a comment in the codebase instead of as code. Teams that have the matrix in a wiki page and not in a function inevitably get a case wrong, usually by assuming Standard direct charges debit the platform when they actually debit the connected account. The vendor pings support to ask why their balance dropped, the platform engineer pulls the docs, and the entire team realizes they have been processing disputes incorrectly for months. The map belongs in getLiabilityRule or whatever you name it, with unit tests for every account-type-plus-charge-type combination.

The second failure mode is reversing the transfer immediately on charge.dispute.funds_withdrawn instead of waiting for charge.dispute.closed. The intuition is right — recover the money fast — but the consequence is that you reverse a transfer that gets reinstated when the dispute is won, and now the vendor's balance is short by the full dispute amount with no funds in the dispute holding account to refund it from. Wait for the close. The dispute-holding ledger entry exists exactly so you can leave the cash position pending across that window.

The third is missing the evidence deadline. The window is 7 to 21 days depending on the card network, but the way it usually fails is not a hard miss — it is a soft miss where evidence gets submitted at hour 23 of day 7 and a Visa CE 3.0 eligibility check that needed an extra signal does not run. The fix is a two-deadline system: an internal soft deadline 48 hours before the Stripe hard deadline that triggers a human review, and the hard deadline as the absolute submit-by point. Track both as columns on the dispute row, alert on the soft deadline, fail the case to a human at the hard deadline.

The fourth is over-trusting charge.dispute.updated. The event fires when the dispute changes, including when evidence is added, but Stripe's docs do not promise that every meaningful change has a corresponding event. Teams that build state machines that depend on updated firing in a specific sequence end up with stuck cases. Treat updated as a hint to refresh the dispute object from the API, not as the authoritative state change.

The fifth is ignoring charge.dispute.funds_reinstated. When a dispute is won, the funds come back to the platform balance, and the ledger entry that recorded the withdrawal needs to be reversed. A handler that only listens to charge.dispute.closed and not to funds_reinstated ends up with a ledger that records every dispute as a permanent loss, even the ones the platform won. Finance catches this eventually, but only after the reconciliation between Stripe balance and internal ledger has been off for a quarter.

The sixth is not caching the charge-to-transfer mapping at charge creation time. The dispute object references the charge, but to recover from the vendor you need the transfer ID, which requires either a Stripe API call to expand the charge or a local lookup. Under dispute time pressure, the local lookup is the only acceptable answer. Store the transfer ID on the charge row when the charge is created, and the recovery path becomes a single SQL query instead of an API round trip.

The seventh is the on_behalf_of confusion. A destination charge with on_behalf_of set still debits the platform balance for disputes — the on_behalf_of field controls the merchant-of-record display, not the dispute liability flow. Stripe's docs are explicit that the platform-debit rule applies "with or without on_behalf_of." Teams that read on_behalf_of as "the connected account is now the merchant of record so they handle disputes" build the wrong recovery path and discover the gap when the first dispute hits.

What this looks like in production

At BFEAI the Connect side of the platform handles vendor payouts for the AI-services side of the business. The dispute volume is low — single-digit cases per quarter — but each case is high-value enough that the recovery path actually matters. The pattern above is what runs in production: a liability map in code, three webhook handlers wired to a dispute lifecycle state machine, an evidence pipeline that enforces the soft and hard deadlines, and a transfer-reversal worker that runs on charge.dispute.closed with a lost status.

The dashboard that matters has four rows. Disputes in evidence_pending state where the soft deadline is within 48 hours — these go to a human for review. Disputes in funds_withdrawn state where the close event has not arrived after 30 days — these get a status refresh from the Stripe API to catch missed webhooks. Disputes closed as lost where the transfer reversal has not yet been scheduled or executed — these are the recovery-path failures. Connected accounts with a negative balance older than 30 days — these are the cases that will hit the 180-day platform-reserve transfer if not resolved, and they get manual outreach to the vendor.

The runbook for a lost dispute is short by design. Pull the dispute record. Confirm the transfer reversal ran cleanly. If the vendor's balance covered it, the loss is closed and the ledger reconciles. If the vendor's balance went negative, watch the negative-balance recovery flow — Stripe will attempt the external account debit where supported, and the dispute case stays open in the system until that resolves. If the negative balance is still there after 30 days, the platform-side action is to either pause the vendor's payouts until the balance comes positive or escalate to a manual collection. The 180-day platform-reserve transfer is the backstop, not the plan.

The single metric that proves the pattern works is dispute-to-recovery time. From charge.dispute.funds_withdrawn to either a funds_reinstated event (won case) or a successful transfer reversal (lost case with vendor-funded recovery), the median should be under the dispute-close window plus a day. When that number drifts up, the cause is typically one of two things: vendors with chronic negative balances that the recovery worker keeps retrying against an empty account, or a code path that is creating charges without the right account-type tag so the liability map returns the wrong rule. Both are fixable, and both are visible from that one metric.

What to watch in your own implementation

Open the code that creates Connect charges and confirm two fields are persisted to your database for every charge: the connected account ID and the charge type (direct, destination, or separate charges and transfers). If either is missing, the dispute handler cannot consult the liability map correctly and you will recover from the wrong party — or fail to recover at all. The audit is fast: a query against your charges table grouped by these two fields should show zero NULLs.

Then open your webhook handler for charge.dispute.created and confirm three things: the handler persists the original transfer ID alongside the dispute, it computes the evidence-due-by deadline from the dispute object rather than reading it from the Dashboard, and it routes evidence collection to the liable party. Missing any of these will work for the first dispute and fail silently on the second when the case shape changes.

Finally, search your codebase for any code path that calls stripe.transferReversals.create and confirm it is gated on the dispute being closed with a lost status. Reversals that fire on funds_withdrawn are the most common cause of vendor-balance issues that finance has to clean up by hand. The reversal worker should be one function in one place, with one trigger, and with a unit test that asserts it does not run on a won close.

Outcomes you should expect

What this delivers

  • Engineering knows in advance which account type debits which balance, so a dispute does not surprise the platform's cash position.
  • Every dispute event has a defined handler and a defined recovery path, so finance can reconcile dispute losses against vendor payouts on a schedule.
  • Evidence submission happens inside the 7-to-21-day card-network window automatically, not in a last-minute scramble when someone notices the deadline in the Dashboard.
  • When a connected account goes negative from a dispute, the recovery logic runs before the 180-day platform-reserve transfer fires, so the loss does not silently land on the platform balance sheet.

Primary sources

By the numbers

  • For destination charges and separate charges and transfers, with or without on_behalf_of, Stripe debits dispute amounts and fees from your platform account.

    Source ↗

  • For Standard accounts, the connected account is liable for direct charges while the platform is liable for destination charges; for Express and Custom accounts the platform is typically responsible for handling disputes and refunds.

    Source ↗

  • Stripe recommends setting up a webhook to listen to charge.dispute.created events so the platform can attempt to recover funds when a dispute is opened.

    Source ↗

  • charge.dispute.funds_withdrawn fires when funds are removed from your account due to a dispute, and charge.dispute.funds_reinstated fires when funds are reinstated after a dispute is closed.

    Source ↗

  • The window for responding to a dispute is usually 7 to 21 days, depending on the card network, and if you do not respond before the deadline you automatically lose the dispute and cannot retrieve the disputed funds.

    Source ↗

  • If a connected account's balance remains negative for 180 days, Stripe transfers funds from the platform reserve to cover the negative amount.

    Source ↗

  • Mastercard evidence files are limited to a combined maximum of 19 pages, and you have only one opportunity to submit your response to a dispute.

    Source ↗

Live in production today

The same engineering, shipped in production at BFEAI.

I'm co-founder & CTO of Be Found Everywhere (BFEAI), a 7-app AI SaaS platform running today. The work I deliver for clients is the work I do every week on my own platform.

7

Production apps

200K+

Keywords generated

1,500+

AI scans run

7,000+

Sites automated

Common questions

What buyers ask before reaching out

Who actually pays when a buyer disputes a charge on my Connect platform?

It depends on the account type and the charge type. For Standard accounts on direct charges, the connected account is liable. For destination charges, separate charges and transfers, and most flows with Express or Custom accounts, Stripe debits the disputed amount and the dispute fee from your platform account first. Recovering it from the vendor is then a manual transfer reversal, not an automatic clawback.

What is the difference between charge.dispute.funds_withdrawn and charge.dispute.created?

charge.dispute.created fires when the buyer opens the dispute with their bank. charge.dispute.funds_withdrawn fires when Stripe actually pulls the money out of your account to cover it. They can fire close together, but they are different signals and need different handlers. The first one starts your evidence flow. The second one is the cash event your finance system needs to record.

How long do I have to submit evidence?

Stripe documents the window as usually 7 to 21 days depending on the card network, and you have only one opportunity to submit. If the deadline passes with no response, the dispute is automatically lost and the funds are not recoverable. For Mastercard specifically, the combined evidence file is capped at 19 pages, so the submission pipeline needs to enforce that limit before it queues a response.

Does Stripe automatically recover a dispute loss from the vendor's connected account?

No. On destination charges, Stripe debits the platform first and leaves the recovery to you. The recovery mechanism is a transfer reversal against the original transfer, and that has to be triggered by your code or by a human in the Dashboard. Until you do it, the vendor keeps the money and the platform carries the loss.

What happens if a connected account goes negative from a dispute and cannot cover it?

Stripe first attempts to debit the connected account's external bank account if that capability is enabled in the region. If that fails and the balance stays negative for 180 days, Stripe transfers funds from the platform reserve to cover it. The platform absorbs the loss either way — the only question is how long it takes to show up on the platform balance sheet.

Should the platform or the vendor submit evidence?

For Standard accounts on direct charges, the connected account submits because the connected account is liable. For Express and Custom accounts, the platform typically submits because the platform is liable — though Express platforms can enable connected accounts to manage disputes through the Express Dashboard if you want to delegate that. The decision should follow liability, because whoever submits the evidence is the only party that can defend the charge.

How do I match a dispute to the original transfer for reversal?

The dispute object references the charge ID. The charge has a transfer property that points at the transfer to the connected account. Store that mapping in your own database when the charge is created, because the dispute-to-transfer lookup is something you will run on every recovery action and you do not want to hit the Stripe API for it under time pressure.

Do I need separate handlers for each dispute webhook?

You need at least three handlers: one for charge.dispute.created that starts the evidence flow, one for charge.dispute.funds_withdrawn that records the cash impact and triggers the vendor recovery path, and one for charge.dispute.closed that updates the case status and either books the loss or reinstates the funds based on the won/lost/warning_closed outcome. charge.dispute.updated is optional but useful for tracking evidence changes.

Ready to see if this is a fit?

A 15-minute call. No deck, no slides. We talk about what you're shipping and where engineering is the bottleneck. Either way, you walk away with a senior engineer's read on your situation.