← Blog

E-commerce

Stripe Connect audit: nine checks before an agent pays out

A custom Stripe Connect account, a missing capability nobody noticed, an agent that didn't know to look, and €47,000 boomeranged back two weeks after payout. Here is what we check first now.

Jacob Molkenboer· Founder · A Brand New Company· 10 Jun 2026· 8 min
Brass scale, twine-tied paper parcel, chartreuse postcard, red wax seal on ivory paper near a window.

The email came in at 14:07 on a Tuesday. Subject line: Negative balance on your platform account: EUR -47,213.40. The Stripe Connect marketplace had paid out to its sellers nine days earlier. The funds were gone. Two of the underlying charges had been disputed, and because the destination accounts had already swept their balances to their banks, Stripe pulled the money from the platform.

The dispute itself was not the disaster. Disputes happen. The disaster was that an automation agent had run the payout loop without noticing one of the connected accounts was sitting on a transfers capability in pending state. The agent saw charges_enabled: true, treated it as green, and moved on. Nine days and one chargeback later, the platform was holding the bag.

We rebuilt that client's payout flow on a Friday afternoon. Since then we run the same audit before any agent we ship touches money on a Stripe Connect platform. The checklist is below.

Capability state, not the enabled flag

The first instinct is to gate every transfer on account.charges_enabled and account.payouts_enabled. Both of those are booleans. They look like the right thing to check. They are not enough.

Capabilities on a connected account can sit in five states: active, pending, inactive, unrequested, and disabled. A custom account can have charges_enabled: true while a specific capability is sliding from active to pending because a verification document expired or because Stripe asked for an updated UBO declaration. The boolean does not flip until the whole account goes restricted, which can take days. The transfers you ran in between are the ones you regret.

Read the capability explicitly for every flow you are about to run. The Stripe capabilities reference documents the full state machine, including which transitions are silent.

// pre-flight before any transfer
const account = await stripe.accounts.retrieve(connectedAccountId)

const checks = {
  charges_enabled: account.charges_enabled === true,
  payouts_enabled: account.payouts_enabled === true,
  transfers_active: account.capabilities?.transfers === 'active',
  card_payments_active: account.capabilities?.card_payments === 'active',
  past_due_empty: (account.requirements?.past_due ?? []).length === 0,
  no_disabled_reason: !account.requirements?.disabled_reason,
}

const safeToMoveMoney = Object.values(checks).every(Boolean)
if (!safeToMoveMoney) {
  await halt(connectedAccountId, checks)
  return
}

The halt path matters more than the check. The agent stops, logs which specific check failed with the value it saw, and escalates to a human. It does not retry. A retry loop on a missing capability is how you find out three days later that you have been firing the same warning into a Slack channel nobody reads.

Requirements, including the ones not due yet

Stripe's account requirements come in four buckets: currently_due, eventually_due, past_due, and pending_verification. currently_due is the list that will trip you up next week. eventually_due is the list that will trip you up in three months. past_due is the list that already disabled the account.

Agents almost always check currently_due and skip the others. That works until the deadline window arrives and the agent does not know that today is the day a tax ID needs to be on file. We treat any non-empty eventually_due with a current_deadline inside the next 14 days as a soft halt: the agent flags it, a human sees it, an account onboarding link goes out before the agent ever has to deal with a hard stop.

The onboarding link itself is worth a sentence. Stripe's account links expire after a few minutes. The agent regenerates one on demand when the seller clicks through from the email, not in advance. We learned that one by sending 200 sellers expired links on a Sunday night.

Webhook completeness

Most Connect integrations subscribe to account.updated and call it a day. That covers a lot of state transitions, but not the ones that get expensive.

The events we make sure are wired before any agent ships:

  • account.updated for capability and requirements changes
  • capability.updated for state transitions, finer-grained than the parent
  • payout.failed for the bank-side failure, separate from the platform call
  • payout.paid for confirmation the money actually left Stripe
  • charge.dispute.created for the first warning before the funds move
  • charge.dispute.funds_withdrawn for the real hit to the platform balance
  • transfer.reversed for transfers Stripe reversed that you thought were final
  • balance.available for when funds actually become available on the platform

Each event needs an idempotent handler and a place to go in your own database. The agent reads from your database before deciding to act, not from the live Stripe API on every loop. The API is the source of truth. Your database is the source of consistency between agent decisions.

Reserve math the agent does not know

If the platform pays out 100 percent of available balance to sellers and a dispute lands the next day, the platform eats it. This is true even if the seller's account is now empty. The account balances guide is explicit: a negative connected account balance becomes a platform-level liability the moment funds cannot be recovered from the seller.

Pick a reserve. We default to holding back 7 to 10 percent of gross volume for the first 90 days of any new seller, then drop the percentage based on dispute history. The agent does not decide what to pay out. It reads a target payout from a function that takes the gross, the reserve percent, the rolling 60-day dispute rate, and any open disputes, and returns a single number.

Warning

An automation agent without a reserve policy will eventually pay out money the platform owes Stripe. The agent is not the safety net. The reserve calculation is.

Idempotency on every transfer

Every transfers.create call needs an Idempotency-Key. Agents that hit a transient error and retry without one have created the same transfer twice. We use a deterministic key shape so the same payout slot can never double-fire.

const idempotencyKey = `payout:${connectedAccountId}:${payoutDateISO}:v1`

await stripe.transfers.create(
  {
    amount: amountInCents,
    currency: 'eur',
    destination: connectedAccountId,
    transfer_group: `payout-batch:${payoutDateISO}`,
  },
  { idempotencyKey }
)

Same key, same day, same destination, same result. The v1 suffix is there for the day we change the payout schema and need a clean break. Without that suffix you have no way to legitimately reissue a payout that needs to run again.

Charge model decides who eats the loss

One detail that did not directly cause the €47k incident but compounded it: the platform was using destination charges with on_behalf_of. The connected account is the merchant of record. Disputes go against the connected account first, but funds are pulled from the platform if the connected account cannot cover. If the same platform had been on separate charges and transfers, the dispute money would have come out of the platform's own balance directly, which sounds worse but is easier to reason about because there is one place to look.

Neither model is wrong. Pick the one that matches how your accounting and your dispute-response team actually work, and make sure the agent knows which model it is operating under. The pre-flight check looks different in each case.

Test-mode dispute replay

Before any agent goes live on a Connect platform, we run a synthetic chargeback in test mode against a connected account that has already paid out. Stripe lets you trigger this with the test card 4000 0000 0000 0259, which generates a dispute a few minutes after the charge.

The point is not to test that the dispute happens. The point is to watch the platform balance go negative and confirm the agent does the right thing: it stops new payouts, surfaces the negative balance, and routes the alert to a human. If the agent keeps paying out other sellers while the platform balance is underwater, the audit failed and the agent goes back to staging.

Where this fails silently

The thread that ties all of the above together is observability. A Stripe capability sliding from active to pending does not crash anything. An agent with a confidently-worded prompt and no instrumentation will not tell you it went wrong. The expensive failure mode of an agentic system is not when it errors. It is when it does the wrong thing convincingly, on a clean run, with a green log line at the bottom.

For money-moving agents the answer is the same as for any production system. Every decision is logged with the inputs it saw. Every halt path is loud. The human on the other end gets a daily digest of here is what I almost did and chose not to. If you cannot point at the log line where the agent saw the missing capability and stopped, the agent is not safe to run.

The five-minute version

The full audit is about 40 items long. The short version, the one you can run in an afternoon before letting an agent touch live money:

  1. Pull every connected account and confirm capabilities.transfers === 'active' and capabilities.card_payments === 'active' for the flows you actually use.
  2. Flag any account with a current_deadline in the next 14 days.
  3. Confirm your webhook subscriptions cover the eight events above and that each handler is idempotent.
  4. Write down the reserve percentage and where it lives in code. If nobody can answer that question in one sentence, you do not have a reserve policy.
  5. Replay one synthetic dispute in test mode end-to-end and watch what the agent does when the platform balance goes negative.

When we built the marketplace payout agent for the client behind the €47k story, the thing we ran into was that active capabilities can become pending mid-week without a single error firing. We ended up solving it by reading capability state on every payout pre-flight and treating any non-active value as a hard halt with human escalation. The same checklist sits behind every AI agent we ship on a payments platform.

Today's five-minute version: open your Stripe dashboard, go to Connect, sort accounts by current deadline ascending, and look at the top five. That is where your next clawback is hiding.

Key takeaway

charges_enabled is a boolean. The capability behind it is a state machine. Read the state machine before any agent moves money.

FAQ

Why is charges_enabled not enough to gate a payout?

charges_enabled is a coarse boolean. Individual capabilities like transfers can move from active to pending while the boolean stays true. Check the capability state directly before each transfer.

What is the difference between currently_due and eventually_due requirements?

currently_due will disable the account if not provided by the current deadline. eventually_due is what Stripe will ask for next. Agents that only check currently_due get caught when the window flips.

What reserve percentage should a new marketplace start with?

7 to 10 percent of gross volume for the first 90 days of any new seller is a reasonable default. Adjust based on dispute rate, average ticket size, and refund frequency. There is no universal number.

Can an automation agent safely respond to Stripe disputes?

Yes for evidence gathering and submission. No for the decision of whether to contest. A dispute is an accusation from a customer's bank and the response affects merchant standing.

What test card triggers a dispute in Stripe test mode?

4000 0000 0000 0259 charges successfully and then generates a dispute a few minutes later. Use it to replay the negative-balance scenario before any agent goes live.

ai agentsautomationintegrationse-commerceoperationsarchitecture

Building something?

Start a project