A donation is the easiest payment to take and the easiest one to misattribute. Easy to take, because the viewer already wants to give you money. Easy to misattribute, because by the time the chain confirms the tip, the person who sent it is gone — the tab is closed, the stream has moved on, and all you have left is a webhook and whatever you remembered to write down when you created the invoice.
The whole game is making sure that webhook can answer one question: whose tip is this? Get that right and the payout side is almost boring. Get it wrong and you have a balance you can't attribute, a creator asking where their money is, and no way to tell them.
A tip is an invoice with metadata
Mechanically, a creator tip is not special. It's an invoice — fiat-anchored so the viewer gives "five dollars" and not "0.00007 BTC", rate locked at activation, one address per chain, a webhook when it confirms. What makes it a tip instead of a checkout order is one thing: the metadata you attach at creation time.
POST /api/v1/invoices
{
"amount_fiat": "5.00",
"fiat_currency": "USD",
"deferred": true,
"metadata": { "creator_id": "cr_8842", "kind": "tip", "stream_id": "live_3301" }
}
That creator_id is the entire answer to "whose tip is this?" — but only if you set it at creation and never try to reconstruct it later. The temptation, especially early, is to skip the metadata and figure out the creator afterward from the deposit address. Resist it. Addresses get reused across invoices, a chain can deliver two deposits to the same address, and you do not want your attribution logic depending on "which creator was streaming when this address last appeared." Put the identity on the invoice, at the moment you know it for certain, and let it ride.
The checkout the viewer actually sees
The viewer doesn't see any of that. They see a hosted checkout page: an amount in the currency they meant to give, a chain picker, a QR code. They choose the chain their wallet already holds — BTC, ETH or an ERC-20, USDT on Tron, USDC on Base or Solana — scan, and they're done. No bridging, no manual amount math, no math at all. That's the point of a hosted page for tips: the lower the friction, the more tips clear, and a tipping viewer abandons faster than a customer who actually wants the thing in their cart.
Two behaviors are normal on a tip checkout that you'd treat as errors anywhere else:
- Underpaid is a tip, not a failure. A viewer whose wallet shaves a network fee off the top, or who fat-fingers the amount, lands you an
invoice.underpaidinstead ofinvoice.paid. On a checkout you might hold the order. On a tip you almost always credit what arrived and move on — nobody wants to email a viewer to say their generosity was twelve cents short. Decide that policy once, in the handler, and write it down. - Overpaid happens, and it's still that creator's.
invoice.overpaidcarries the samecreator_idyou set at creation. Credit the full amount. The attribution didn't change just because the number did.
Whose tip is this? — answering it from the signed webhook
Here is the part teams get wrong, and it's the reason this post exists. The viewer closes the tab the instant the QR is scanned. If your crediting logic lives in the browser — on the success page, in an onConfirm callback, anywhere client-side — you will lose tips, silently, and you won't know which creator you shorted. The success page is a courtesy. The webhook is the truth.
So crediting lives server-side, in the invoice.paid (and invoice.underpaid, and invoice.overpaid) handler. The payload carries back the metadata you set, so the handler reads creator_id straight off the event and credits that creator's balance. No lookup table keyed on address, no "who was live at the time" heuristic — the answer travels with the event.
Real events only. There is no invoice.activated webhook to credit against — activation is the rate-lock moment, not a settlement signal. The events that mean money is real are invoice.confirming, then invoice.paid / invoice.overpaid / invoice.underpaid. Credit on the settled ones.
And before you trust a single byte of that payload: verify the HMAC signature over the raw request body. Not the parsed JSON — the raw bytes, before any deserialization reorders keys or rewrites whitespace. A tip-crediting endpoint that skips the signature check is a free balance generator for anyone who guesses the URL and POSTs {"creator_id": "cr_me", "amount": "9999"}. The signature is what makes the creator_id in the body yours and not the attacker's. We wrote the long version of why in verify the webhook signature before acting; the short version is: verify, then read the metadata, then credit. In that order, every time.
def on_webhook(req):
if not verify_signature(req.raw_body, req.headers["X-Halfin-Signature"]):
return 401
event = json.loads(req.raw_body)
if event["type"] in ("invoice.paid", "invoice.overpaid", "invoice.underpaid"):
creator_id = event["data"]["metadata"]["creator_id"] # set at creation, signed in transit
credit_creator(creator_id, event["data"]["amount_paid"], event["id"])
That event["id"] is doing quiet, important work: it's your dedupe key. Webhooks retry. The same invoice.paid can land twice, and a tip credited twice is a balance you'll be paying out money you never received. Store the event id you've processed; if it shows up again, no-op. Process by event id, not by invoice_id, and you'll be correct even when the delivery isn't.
Don't split on-chain. Accrue.
A $5 tip is not $5 to the creator — you keep a share. The instinct is to split the payment so the creator's cut lands in their wallet the moment the tip confirms. Don't. A per-tip on-chain payout means a network fee on every $5, and on small tips that's how a revenue share quietly becomes a loss.
Take the whole tip into platform balance and model the split as two ledger entries against one confirmed invoice: the creator's claimable amount and your revenue. Accrue. The creator's balance ticks up with each confirmed tip; the actual money moves later, batched. This also hands you the one identity both sides reconcile against — sum of confirmed invoices equals creator-claimable plus platform revenue plus what's already paid out plus the unpaid float. If that ever stops balancing, you have a bug you can find, because every term traces to an invoice or a payout the system really emitted.
Paying thousands of creators, at scale
Now the money goes the other way. On withdrawal day, or whenever a creator hits "cash out," you're sending to a few thousand addresses. Mass payouts here is a fan-out over the single-payout API, not a batch endpoint — you loop POST /api/v1/payouts, once per creator, and each line carries its own idempotency key as a snake_case field in the request body. There is no /payouts/batches to hide behind, and that's on purpose: a half-succeeded batch that won't tell you which half is worse than ten thousand calls you can each retry without fear.
for creator in withdrawals:
POST /api/v1/payouts
{ "amount": "120.40", "currency": "USDT_TRC20", "destination": "T...",
"idempotency_key": "payout:cr_8842:2026-03" }
Derive the key from the business action — payout:{creator_id}:{period} — not freshly per attempt, so re-running the whole run after a blip is safe by construction: lines that went through return the existing payout, lines that didn't go out. Three rules earn their keep on the payout run:
- Payouts enter pending-approval; release them from the dashboard. The API 200 means accepted, not sent. A run of three thousand creator withdrawals sits in pending-approval until someone with the authority to move money releases it — a gate, not a delay, and the reason a compromised API key can't drain your float on its own.
payout.completedis the only signal money left. Update each creator's "paid out" total off that webhook, never off the request response.payout.failedis the one you actually need to handle — a bad address, an insufficient balance — and theidempotency_keylets you safely re-issue once you've fixed the cause.- Default each creator's rail to what's cheap for their amount. Someone pulling $120 wants USDT on Tron or USDC on Solana; the Ethereum fee eats real money at that size. The treasury pulling $40,000 doesn't notice gas.
The whole loop
It's a small system once you see it as one primitive pointed two ways. A tip arrives as a fiat-anchored invoice carrying the creator_id in its metadata. It confirms; the signed webhook hands that id back, you verify the signature, dedupe on event id, and credit the right creator's balance — never from the browser. Balances accrue, no per-tip on-chain split. On withdrawal day the same money fans back out, one idempotency key per creator, released through pending-approval, confirmed by payout.completed. Money in and money out ride the same rails and reconcile against the same events.
If you're building the reconciliation and ledger side in depth, creator payouts and donations at scale is the companion to this one. This post is about the question that comes first: when the tip confirms and the viewer is long gone, the webhook still knows exactly whose money it is — because you told it, at creation, and signed it in transit.
S. Brandt, halfin solutions