What a crypto affiliate payout actually is
There is no single "send this batch" button to press, and you do not want one. A halfin payout run is a fan-out: you loop over your list of payees and submit one payout request per line to POST /api/v1/payouts. There is no /payouts/batches envelope — the unit of work is a single payout, and a run is simply many of them sent in sequence. That sounds less convenient than a bulk endpoint, but it is what makes the run safe to retry, because each line stands on its own and carries its own idempotency key.
Each payout line is four fields: the currency (a crypto asset code such as USDT or USDC), the amount as a string, the destination wallet address, and an idempotency_key that you assign. The asset and the destination together pin which chain the payout settles on — a USDT line going to a Tron address settles on Tron, a USDC line going to a Solana address settles on Solana. You do not pass a separate network field; the address determines the rail, and you are responsible for making sure the address matches the asset's chain.
The currency is always a crypto code, never a fiat one. If your affiliates are owed amounts denominated in dollars in your own books, you decide the asset and convert the figure to that asset's amount before you build the payout line — halfin pays out the asset and amount you submit, it does not price a USD commission into crypto for you on the payout side. Most programs settle commissions in a stablecoin precisely so that "owed $200" maps cleanly to "pay 200 USDT" without an FX step.
Step 1 — Build the run from your own commission data
Start from the source of truth you already have: the commission ledger for the cycle. For each payee you need four things — the asset to pay them in, the amount in that asset as a string, their verified destination address on the matching chain, and a stable identifier for this payee-and-cycle that you will turn into the idempotency key. A flat CSV with one row per payee is enough; the loop in Step 3 reads exactly that shape.
Validate the destination before it ever reaches the run. A payout sends real value to whatever address you submit, and an address typo or a chain mismatch — a Tron address on a line you meant to settle as ERC-20 USDT — is value gone, not a failed request you can replay. Check each address against the asset's chain format at the point where affiliates enter their payout details, store the validated pair, and treat the stored value as the only thing the run is allowed to use. Keep wallet validation and approval inside your own controls; that ownership is the whole point of running the fan-out yourself.
Make the identifier deterministic. The idempotency key is what guarantees each payee is paid once across the whole cycle even if your job runs twice, so derive it from something that is unique to this payee and this cycle and never changes — an affiliate id joined to the cycle, for example payout-2026-06-aff_4821. Do not use a random UUID generated at send time: a retry would generate a fresh one, the keys would not collide, and the payee would be paid twice. The key must be the same on the first attempt and every retry.
- One row per payee: asset, amount (string), validated destination, payee id.
- Validate the destination against the asset's chain before the run, not during it.
- Settle commissions in a stablecoin so "owed $X" maps to "pay X USDT/USDC" with no FX step.
- Derive the idempotency key from affiliate id + cycle — deterministic, never random.
Step 2 — Gate the run behind approval and a scoped key
Submitting a payout does not move money on its own. Each payout you POST enters a pending-approval state on halfin and is released from the dashboard before any funds leave your balance — approval is the gate that separates creating a payout from executing it. So the fan-out script stages the run; an operator with payout permissions reviews and releases it. That built-in hold is your last line of defense against a bad address or a wrong total reaching the chain.
Layer your own pre-run sign-off on top of the platform gate. Before you even fan the cycle out, have the run total and the payee count approved the way you approve any outbound spend — a finance review, a second-person check, a cap on the per-run total, whatever your business already uses for disbursements. The script executes an approved decision, the dashboard approval releases the funds, and the two controls compound rather than substitute for each other.
Separate the credential that pays from the credentials that read. halfin API keys carry scoped permissions, so the service that runs the affiliate payout holds a payouts-scoped key and nothing else. The key your analytics dashboards use to read invoices and the key your storefront uses to create invoices should not be able to move money. If a reporting credential leaks, the blast radius is read access, not your treasury — and the payouts key lives only in the disbursement service's secret manager, never in source control and never in the spreadsheet next to the recipient list.
Decide your failure policy before you run, because a run of several hundred lines will usually have a few that do not go through — an address that fails downstream validation, a momentary error. Choose up front whether a failed line halts the run for review or is collected and retried at the end, and make sure the operator running the cycle knows which it is. The idempotency keys make either choice safe: re-running the whole file after fixing the failures re-sends every line, and the ones that already settled are no-ops.
Step 3 — Fan out one idempotent payout per payee
With the validated list in hand and the run approved, the execution is a loop. Read each row, post one payout to POST /api/v1/payouts with the four fields, and let the idempotency key do the safety work. The script below reads a CSV of payee,currency,amount,destination and submits a line for each; the idempotency key is built from the cycle and the payee id so the same row always carries the same key.
The guarantee this buys you is the important part. If the script dies halfway through the file, or the network drops on line 200, or an operator runs the cycle twice by mistake, you re-run the same file. Every payout whose idempotency key was already accepted is recognized as a duplicate and not paid again; only the lines that never got through are settled. You never reconcile a double-payment to an affiliate after the fact, because the run cannot create one.
Send amounts as strings — monetary values are strings end to end, never floats. The same call works through the @halfin/sdk-merchant TypeScript client if your disbursement service is in TypeScript; the field names and the full response schema are in the API reference at docs.thehalfin.com. Capture the response for each line — the payout id especially — and store it against the payee and cycle so Step 5 can match the webhook back to the right commission.
# Fan out an affiliate payout cycle. One POST per payee; each line carries a
# deterministic idempotency key, so re-running the same file never double-pays.
# recipients.csv columns: payee_id,currency,amount,destination
while IFS=, read -r payee currency amount destination; do
curl -sS -X POST https://api.thehalfin.com/api/v1/payouts \
-H "Content-Type: application/json" \
-H "X-API-Key: $HALFIN_PAYOUTS_KEY" \
-d "{
\"currency\": \"$currency\",
\"amount\": \"$amount\",
\"destination\": \"$destination\",
\"idempotency_key\": \"affiliate-2026-06-$payee\"
}"
done < recipients.csv
# Crash on line 200? Re-run the same file. Keys collide on every line that
# already settled, so only the unsent payees are paid. See docs.thehalfin.com
# for the full request and response schema.Step 4 — Match assets to the chains affiliates actually use
Affiliates do not all want the same asset on the same chain. A media buyer paid in USDT may want it on Tron because that is what their exchange and their off-ramp speak; a developer affiliate may prefer USDC on Base or Solana for low fees; some payees only hold BTC. The payout line settles on whatever chain the destination address belongs to for the given asset, so the run can carry a mix of rails in the same file — each line is independent.
Pay on the rails halfin actually settles, and collect the address that matches. The table below is the real supported surface for the assets affiliate programs lean on; do not collect a destination on a chain that is not listed for that asset. Letting each affiliate pick their asset-and-chain at enrollment — and validating the address against that choice — is what keeps the run clean: by the time you build the cycle, every row already names a settleable pair.
| Asset | Networks you can pay on | Why an affiliate picks it |
|---|---|---|
| USDT | Tron (TRC-20), Ethereum (ERC-20), Solana | Most common stablecoin payout; Tron for low fees and wide exchange support |
| USDC | Ethereum (ERC-20), Base, Solana | Stablecoin payout for US / EVM-native payees; Base and Solana for low fees |
| BTC | Bitcoin | Payees who only hold or want Bitcoin |
| ETH | Ethereum | EVM-native affiliates settling in the chain's own asset |
| SOL | Solana | Fast, low-fee settlement for Solana-native payees |
| XRP | XRP Ledger | Fast finality for payees on the XRP Ledger |
| Native L2 / chain assets | Base, Arbitrum, Polygon, BNB Smart Chain | Pay in the asset the affiliate already holds on that chain |
Step 5 — Reconcile and audit from signed webhook events
Submitting a payout is not the same as the money landing. The request is accepted, the payout waits for release from the dashboard, then the platform broadcasts the transaction and it confirms on-chain over the chain's own pace. The signal that a payout reached its terminal state is a webhook: halfin sends your server an HMAC-signed event when a payout completes or fails, independently of your run script. Drive your reconciliation off those events, not off the fact that the POST returned.
Verify before you trust, the same rule as everywhere else on the platform. Recompute the HMAC over the raw request bytes and compare it in constant time before you read the body as a business fact; an unsigned or mismatched request is not a halfin event and must never mark a commission as paid. Once verified, a payout.completed event means that payee's commission settled — record it against the payee and cycle using the payout id you stored in Step 3. A payout.failed event means that line did not go out: surface it, fix the cause (often a bad address that slipped past validation), and include the payee in the retry, where the unchanged idempotency key keeps the rest of the cycle untouched.
Keep the handler idempotent, because webhook delivery is at-least-once: a redelivered payout.completed carries the same stable event id, and marking a commission paid twice or emailing the affiliate twice are exactly the bugs that surfaces. Dedupe on the event id and make the side effect a no-op the second time. The audit trail falls out of this for free: every payee in the cycle has a payout id, a settled or failed terminal event, and the run record that produced it — a finance reviewer can answer "did affiliate X get paid, when, and on what chain" without reconstructing anything.
- Reconcile off payout.completed / payout.failed events, not the POST response.
- Verify the HMAC over the raw bytes in constant time before acting on any event.
- Map the event to a payee via the payout id you stored when you fanned the run out.
- Dedupe on the event id — delivery is at-least-once; mark each commission paid once.
- Retry failed lines with the unchanged idempotency key; settled lines stay no-ops.
A note on payouts, conversion, and what halfin does not do
Affiliate payouts spend a crypto balance you already hold on the platform — typically what your invoices and deposits accrued. If the asset your affiliates want to be paid in is not the asset sitting in your balance, balance conversion rebalances between assets on the platform first, so the payout run has the right asset to draw from. Conversion here is asset-to-asset treasury movement, not a cash-out.
Be precise about the boundary: halfin does not move money to a bank account. There is no fiat off-ramp — a payout sends a crypto asset to a crypto address, and that is the end of the rail halfin operates. If an affiliate ultimately wants dollars in a bank, converting the crypto they receive into fiat is something they do on their side, with their own provider; it is not a step in your payout run. Build the cycle around the asset you are paying, settle it to the affiliate's wallet, and let the audit trail of payout ids and signed events be the record of what you sent.