Guest post by D. Okonkwo, head of finance ops at a marketplace platform that closes ~4,000 sellers per week.
I joined this company two years ago. The first thing I did was open the seller-payout pipeline. The second thing I did was close my laptop and go for a walk.
This is what I wish I'd known on day one.
The problem, in one sentence
A marketplace doesn't have a payroll. It has a settlement engine. Every week, 4,000 sellers expect a payout that's the right amount, on the right rail, on the right day, with a receipt they can match against their own books. Get any of those wrong and you have a support ticket. Get them wrong consistently and you have churn.
The first thing I built (and regretted)
I tried to manage the settlement pipeline as a series of CSV exports out of our backend. Every Friday morning, a script generated a CSV; finance reviewed it; it got uploaded to our payments rail; it ran.
Two problems showed up immediately:
- No idempotency. If the upload errored halfway, we had no clean way to know which rows had paid and which hadn't.
- No audit trail back to the seller's transaction record. A payout was just a row in a CSV; the link back to which orders it covered was a manual reconciliation.
We were one mis-keyed re-upload away from double-paying 4,000 sellers, and the only thing standing between us and that outcome was that I was nervous enough to triple-check every Friday.
The second thing I built
A single Postgres view. The shape:
seller_id | payout_window | gross | fees | refunds | net | rail | status
Every row joins a settlement window (the 7-day chunk we're paying out) to a seller record and to a rail decision (which network the seller's wallet wants). The view gets recomputed every Sunday night. Monday morning, finance reviews; Tuesday morning, the batch runs.
The Postgres view is the source of truth. The CSV export is a downstream artifact. Don't let the CSV become the source of truth.
What we hand off to the rail
A single batch payout call with an idempotency key. The rail (in our case, halfin) handles the network-level fan-out, the per-seller status, and the webhook stream back to our system.
Our backend listens to payout.completed and payout.failed events and updates the view. Failed payouts roll into the next window; the seller's record marks the failure reason. The seller can see the status from their dashboard.
The dispute window
We hold a 7-day rolling reserve against any seller's pending payout. If a buyer disputes a transaction inside that window, we can claw back the disputed amount from the reserve before the payout settles. After the window closes, the funds are committed.
This was non-negotiable for our risk team. It also means the seller's available balance and their pending balance are two different numbers. The UI has to make that obvious.
The Postgres view I wish I'd built sooner
A second view, derived from the first:
seller_id | trailing_30d_gross | trailing_30d_fees | trailing_30d_disputes | health_score
Health score is a simple weighted formula. Sellers with high dispute rates or sudden spikes get flagged for manual review before the next payout. We cut our chargeback losses by 60% in the first quarter after this view shipped.
What I'd tell my past self
- Build the Postgres view first. Everything downstream is easier.
- Pick one settlement rail per region; don't sprawl. We support TRC-20 in APAC, ERC-20 in NA/EU, SPL for fast settlements. Three rails is enough; five is a maintenance burden.
- Make the dispute window visible to the seller. They will ask. Make the answer self-serve.
- Treat batch payouts as atomic. Either the whole window settles or none of it does.
D. Okonkwo
