← halfin journalMay 27, 2026 · 10 min read
Playbooks

Creator payouts and donations at scale: money in, money out, on the same rails

A working blueprint for a creator platform that takes crypto tips at the checkout and pays thousands of creators back out, without two reconciliations that disagree.

RA
R. AdeyemiPayments Engineering
playbooks · cover

The shape of a creator platform's money problem is two arrows pointing in opposite directions. Viewers send tips, subscriptions, and one-off donations in. Creators withdraw their balance out. The platform keeps a cut in the middle. Most teams build the first arrow well and the second one badly, then spend a quarter trying to figure out why the two ledgers disagree by 0.4%.

This is the version we'd build today, on one set of rails, so the money-in and money-out sides reconcile against the same numbers.

The donation side: one checkout, every chain

A tip is a payment with a name on it. Mechanically it's the same primitive as any other invoice: a fiat-anchored amount, a rate locked at activation, an address per chain, a webhook when it confirms.

The mistake is building a bespoke "tip widget" that reinvents address management and confirmation tracking. Don't. A donation is an invoice where the metadata says who the tip is for and what it's attached to (a stream, a clip, a creator ID). You create it fiat-anchored:

POST /api/v1/invoices
{
  "amount_fiat": "5.00",
  "fiat_currency": "USD",
  "deferred": true,
  "metadata": { "creator_id": "cr_8842", "kind": "tip", "stream_id": "live_3301" }
}

The viewer sees a hosted checkout page, picks the chain their wallet already holds — BTC, ETH or an ERC-20, USDT on Tron, USDC on Base or Solana — and the rate is locked the moment the invoice activates. They are not doing math. They are not bridging. They scan a QR and the donation is denominated in the dollars they meant to give.

Two things matter on the donation side that generic payment flows get wrong:

  • Underpaid and overpaid are normal, not errors. A viewer who fat-fingers an amount, or whose wallet shaves a network fee off the top, lands you an invoice.underpaid instead of invoice.paid. On a checkout, you'd block the order. On a tip, you almost always want to credit what arrived and move on. Decide that policy once, in the invoice.underpaid handler, and write it down.
  • The webhook is the source of truth, not the browser. The viewer closes the tab the second the QR is scanned. If your credit logic lives in the success page, you'll lose tips. It lives in the invoice.paid (and invoice.underpaid) webhook handler, server-side, every time.

Verify the HMAC signature on the raw body before you credit anything. We wrote the long version of why in designing webhooks that survive everything — the short version is that a tip-crediting endpoint with no signature check is a free balance generator for anyone who finds the URL.

The split: where the platform's cut lives

A $5 tip is not $5 to the creator. The platform keeps a share. The clean way to model this is to not split the payment on-chain. You receive the full tip into platform balance, then track the creator's claimable amount and the platform's revenue as two ledger entries against one confirmed invoice.

Resist the urge to send the creator's portion the instant the tip confirms. Per-tip payouts mean a network fee on every $5, which on small tips is how you turn a revenue share into a loss. Accrue. The creator's balance goes up by their share of each confirmed tip; you pay it out on a cadence or on demand, batched.

This also gives you the one number both sides reconcile against: sum of confirmed invoices = sum of creator-claimable + platform revenue + amounts already paid out + unpaid-out float. If that identity ever breaks, you have a bug, and you can find it, because every term traces to an invoice or a payout the system actually emitted.

The payout side: idempotent, batched, one key per line

Now the second arrow. On withdrawal day — or whenever a creator hits "cash out" — you're sending money to a few thousand addresses. This is the part that bites.

halfin's mass payouts are a fan-out over the single-payout API, not a magic batch endpoint. You loop POST /api/v1/payouts, once per creator line, and each line carries its own idempotency_key. There is no POST /payouts/batches to hide behind, and that's deliberate — a batch that half-succeeds and leaves you guessing which half is worse than ten thousand independent calls you can each retry safely.

The key is derived from the business action, not generated fresh per attempt. We use something like payout:{creator_id}:{payout_period}. Re-running the whole batch after a network blip then becomes safe by construction: lines that already went through return the existing payout, lines that didn't go out this time. No double-pays, no spreadsheet at 2am cross-checking who got paid twice.

for line in batch:
  POST /api/v1/payouts
  { "amount": "120.40", "currency": "USDT_TRC20", "destination": "T...", "idempotency_key": "payout:cr_8842:2026-05" }

A few field-tested rules for the payout run:

  • Let creators pick the rail, default it to what's cheap for their amount. A creator pulling $120 wants USDT on Tron or USDC on Solana — the fee on Ethereum eats real money at that size. A creator pulling $40,000 to a treasury doesn't care about gas. We walked through that trade-off in USDT on TRC-20 vs ERC-20; the same logic decides your withdrawal defaults.
  • Treat payout.completed as the only signal that money left. Not the API 200 — that means accepted. The webhook means sent and confirmed. Update the creator's "paid out" total off payout.completed, never off the request response.
  • Validate addresses against the chain before you submit. A creator who pastes an Ethereum address into a Tron payout field is a support ticket if you catch it and a lost payout if you don't.

"Instant" withdrawals, honestly

Creators want their money now, and crypto is the rare rail where "now" is achievable — no three-day ACH, no banking-hours cutoff. A withdrawal request that passes your checks can be a single POST /api/v1/payouts that confirms in seconds on Solana or Tron.

But be honest about what "instant" costs. Instant means no batching window, which means a fee per withdrawal, which means a minimum withdrawal threshold so a creator can't drain you with $1 cash-outs. And instant means your fraud and balance checks happen synchronously before the payout fires, not in a nightly sweep. Put the gate before the send: confirmed-balance check, threshold check, address validation, then submit. The idempotency_key still protects you if the creator double-taps the button.

The platforms that get this right offer both: a free scheduled payout on a cadence, and an instant on-demand withdrawal above a threshold that carries its own fee. The cadence keeps your fee load sane; the instant option is the feature creators actually brag about.

The whole loop, on one set of books

Put it together and the system is small:

  1. Tip comes in as a fiat-anchored invoice on the chain the viewer already holds. Credit on the webhook, not the browser.
  2. Confirmed tip splits into creator-claimable and platform revenue as ledger entries — no on-chain split, no per-tip fee.
  3. Creator balance accrues; payouts go out batched or instant, one idempotency key per line, status driven by payout.completed.
  4. One reconciliation identity ties all of it back to invoices and payouts the system emitted.

Money in and money out ride the same rails, settle in the same assets, and reconcile against the same confirmed events. That's the whole trick: not two systems that have to agree, but one system viewed from two directions.

If you're building this for streaming or a creator marketplace, the streamers use-case page has the surface-level pitch; this post is the part you'll actually implement. Start with the donation invoice, get the webhook crediting right, and the payout side is the same primitive pointed the other way.

R. Adeyemi, halfin payments engineering

↳ end of articlehalfin journal · May 27, 2026