← All articles

Automated Marketplace Payouts with Stripe Connect: Idempotent Sweeps and Clawbacks

By Shuvo Haldar5 min read
StripeStripe ConnectMarketplaceNode.jsPayments
Automated Marketplace Payouts with Stripe Connect: Idempotent Sweeps and Clawbacks

The scary part of a marketplace isn't taking the customer's money. It's paying everyone else back out. On the laundry marketplace I built, once a bag was delivered the platform owed the shop money — minus a delivery fee, minus a commission, minus a weekly listing charge — and it had to do that on a schedule, for hundreds of orders, without a human checking the maths.

I wrote a scheduled settlement sweep on top of Stripe Connect to do it. This is the part the manual-capture post promised was a story of its own.

Animated diagram: settleable orders delivered over 48 hours ago flow into a sweep that sums supplier shares, subtracts pending clawbacks, and emits one idempotent Stripe transfer to the shop's Connect account.
Each sweep groups a shop's eligible orders, nets them down, and sends a single replay-safe transfer.

The rule: pay 48 hours after delivery, net of everything

Each order splits into three pieces the moment it completes: a flat delivery fee, a percentage commission, and a separate weekly listing subscription. All of that is config-driven and overridable per supplier, because the business kept changing its mind about the numbers. What a shop is actually owed for an order is the captured amount minus its share of those fees.

I don't pay per order. I run a sweep on a schedule that groups a shop's eligible orders — anything delivered more than 48 hours ago and not yet settled — sums what's owed, subtracts any pending clawbacks, and sends one transfer.

// One settlement run for one supplier
const orders = await getSettleableOrders(shopId, { deliveredBefore: hoursAgo(48) });

const gross = orders.reduce((sum, o) => sum + o.supplierShareCents, 0);
const clawbacks = await getPendingClawbacksCents(shopId);
const net = gross - clawbacks;

if (net <= 0) {
  // Nothing to pay, or we're still recovering a refund — record and stop.
  await recordSettlement({ shopId, net: 0, clawbacksApplied: clawbacks, orders });
  return;
}

await stripe.transfers.create(
  { amount: net, currency: "aud", destination: shop.connectAccountId },
  { idempotencyKey: `settle:${shopId}:${runId}` }, // <- the whole safety net
);

That idempotencyKey is the line that lets me sleep. Scheduled jobs get retried — the box restarts mid-run, the cron fires twice, a deploy interrupts the loop. With a key that's deterministic per shop per run, Stripe treats a replay as the same transfer and returns the original result instead of paying twice. I never want to reason about "did this already send?" from my own database state; I want Stripe to enforce it.

Clawbacks: when the money's already gone

Here's the case that breaks naive payout systems. A customer gets refunded after the supplier has already been paid for that order. The platform is now out of pocket, and the money is sitting in the shop's Stripe balance.

Diagram of three sweep runs: run 1 pays 100 then the customer is refunded 80; run 2 owes 30 but carries an 80 clawback so it pays 0 and carries 50 forward; run 3 owes 120 minus a 50 clawback and pays 70, clearing the debt.
A refund after payout becomes a clawback that later sweeps net against — the payout floors at zero and the debt carries forward.

I don't try to pull it back directly. I write a clawback — a negative amount owed to that shop — and let the next sweep absorb it. The shop's following payout is reduced by what they owe. That's why the sweep computes gross - clawbacks and guards net <= 0: a supplier with more clawbacks than new earnings simply gets a zero payout that run, and the remaining debt carries forward. The payout is never negative — you can't send a negative transfer, and you shouldn't try to model one.

Why a sweep beats per-order transfers

I'll say it plainly: I prefer batch settlement over paying each order as it completes, and it's not close.

  • Fewer transfers, fewer fees, fewer moving parts. One transfer per shop per cycle instead of one per order.
  • Clawbacks have somewhere to net against. If you pay per order the instant it's done, a later refund has nothing to subtract from and you're chasing money.
  • The 48-hour window is a built-in dispute buffer. Most "actually, that was wrong" events happen right after delivery. Settling immediately means settling before you know the order is really final.

The one thing a sweep demands is idempotency, because a batch job that half-runs and retries is the perfect way to double-pay. Get the key right and the rest is arithmetic.

Keep the money maths in one tested place

Every amount here is integer cents, computed on the server, in a single pricing module that's unit-tested on its own. The supplier share, the commission, the GST-inclusive totals — none of that is scattered across route handlers. When finance asks "why did this shop get exactly this much this week," I can point at one function and the settlement record it wrote, which lists every order, the fees applied, and the clawbacks netted.

Takeaways

  • Settle marketplaces on a scheduled sweep, not per order — it gives fees and clawbacks a place to net.
  • Make every transfer idempotent with a deterministic key; assume the job will retry.
  • Model refunds-after-payout as clawbacks that carry forward, and guarantee the payout can never go negative.
  • Compute every figure in integer cents on the server, in one place you can test and point to later.

None of this is exotic Stripe API usage. It's a handful of ordinary calls wrapped in the right guarantees — which is exactly what moving other people's money should feel like.


I build payment systems, marketplaces, and SaaS platforms end-to-end. If you're working on something similar, let's talk.

Building something similar?

I'm a senior full-stack & DevOps engineer available for full-time roles and projects worldwide.