The model: there is no batch endpoint, and that is the point
Before any code, get the shape right, because it determines everything downstream. halfin does not expose a batch payout endpoint — no /payouts/batches, no single call that takes an array of recipients. A mass payout is exactly what it sounds like: many calls to POST /api/v1/payouts, one per recipient, run in a loop. The platform does not hold a 'batch' object you query later; the batch is a concept that lives in your code and your ledger.
That sounds less convenient than a single batch call until you hit the failure that actually costs money: a payout run that dies partway through. With a monolithic batch endpoint, a mid-run crash leaves you asking 'did the batch half-apply?' With a fan-out of independent, idempotent calls, the question disappears — you re-run the whole loop and the platform deduplicates. The safety comes from the per-line idempotency key, not from a server-side batch transaction.
So the unit of work is the single payout, and the unit of safety is the key. Everything in this guide is about running that loop so that a timeout, a worker restart, or a re-run of the file never produces a second payment to anyone.
- No /payouts/batches endpoint — you loop POST /api/v1/payouts once per recipient.
- Each call carries its own deterministic idempotency_key; that is what makes the run safe.
- A retried or re-run loop converges on exactly one payout per recipient.
- Partial failure is normal: bad lines reject individually, good lines still go through.
Step 1 — Build the recipient set from your own ledger
Start from a source of truth you already own — your commissions table, your withdrawal queue, your contractor roster — not a hand-edited spreadsheet. Each recipient line needs exactly four things: the currency (asset plus network), the amount as a string, the destination address, and an idempotency_key you can re-derive. Those four fields are the entire payout line; there are no other request fields to set.
Pick the currency per recipient, not per run. Recipients do not share a preference, and sending USDT on a network an address cannot receive just produces a rejected line. A trader may want USDT on Tron; a US affiliate may need USDC on Ethereum or Base for their own books; a creator may prefer SOL for speed. One run can mix all of them — the loop sends each line on the network that line names.
Keep amounts as strings the entire way through. A USDT payout of "125.50" is transported exactly; the moment you let it become a JavaScript number you have invited binary-rounding drift between your ledger and the chain. If your data comes out of a CSV or a database as text, leave it as text.
| Field | Type | What goes in it |
|---|---|---|
| currency | string | Asset + network the recipient receives, e.g. USDT on Tron, USDC on Base, BTC, ETH, SOL. |
| amount | string | Decimal amount as a string — never a number. Carried exactly from your ledger to the chain. |
| destination | string | Recipient wallet address; validated against the chosen network before the payout is accepted. |
| idempotency_key | string | Deterministic per recipient + run. Re-submitting the same key returns the existing payout. |
Step 2 — Design the idempotency key so a re-run is free
This is the step that makes or breaks a mass payout. The idempotency_key is what lets you re-run the entire loop after a crash without paying anyone twice: when halfin sees a key it has already accepted, it returns the original payout instead of creating a new one. For that to work, the same recipient in the same run must always produce the same key — the key has to be deterministic, derived from data you can reproduce, never a fresh UUID or a timestamp generated at send time.
Compose the key from stable identifiers you already store: the recipient's account id and the payout period or run id. Something like acct_4821:payout-2026-06 is ideal — re-deriving it next Tuesday from the same two facts yields the identical string. A random key defeats the whole mechanism: on the second pass it will not match, halfin will treat the line as new, and the recipient gets paid again. Treat 'is this key reproducible from my records alone?' as the test every key must pass.
Make the key unique at the right grain. If a single recipient legitimately gets two payments in one run — say, a commission and a separate bonus — those need two distinct keys (append the line type or a stable line id), or the second will be swallowed as a duplicate of the first. Conversely, if the same logical payment can be triggered from two code paths, both must derive the same key, or you will double-pay. The key is your contract with the platform about what counts as 'the same payment'.
- Deterministic, not random — re-derivable from your ledger alone, e.g. acct_4821:payout-2026-06.
- Never a fresh UUID or send-time timestamp; that breaks dedup on the retry.
- Unique at the payment grain — two distinct payments to one recipient need two distinct keys.
- Same logical payment from two code paths must derive the same key, or you double-pay.
Step 3 — Run the loop, idempotently
With keys designed, the loop itself is mechanical: walk the recipient set and POST each line to /api/v1/payouts with its X-API-Key, its four fields, and nothing else. Because every call is idempotent, wrapping the whole loop in a retry is correct rather than reckless — if the process dies on line 240 of 600, you re-run from the top and the first 239 come back unchanged while only the missing lines are created.
The shell example below fans out a CSV; the same logic is cleaner in TypeScript via @halfin/sdk-merchant, where you await each create and collect the per-line result. Either way the discipline is the same: derive the key from the row, send the four fields, and record what came back keyed by your own recipient id so you can reconcile later. Send amounts as strings, and let the key — not a server batch — be the thing that makes interruption safe.
Use a scoped API key for this. The service that moves money should hold a payouts-scoped key, separate from the read-only key your reporting uses and the invoicing key your storefront uses. A leaked analytics credential should never be able to initiate a payout.
# Fan out a payout run over the single-payout API — there is no batch endpoint.
# Each line is idempotent, so re-running the whole file after a crash is safe.
while IFS=, read -r account 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_API_KEY" \
-d "{
\"currency\": \"$currency\",
\"amount\": \"$amount\",
\"destination\": \"$destination\",
\"idempotency_key\": \"$account:payout-2026-06\"
}"
done < recipients.csv
# Re-run the same file after a failure: keys collide, already-created
# payouts are returned unchanged, nobody is paid twice.
# See docs.thehalfin.com for the full request and response schema.Step 4 — Triage partial failures instead of aborting
At scale, some lines will be rejected, and that is the normal case — not a reason to roll back the run. A malformed address, an amount under a chain's dust threshold, a currency the destination cannot receive: each of those rejects that one line and reports why, while the rest of the batch proceeds. There is no step where a single bad row blocks two hundred good ones, so do not treat the first rejection as a fatal error and stop the loop.
Capture the per-line outcome as you go. For each recipient, store the key you sent and whether the call succeeded or rejected (and why). When the loop finishes you have a clean two-column picture: accepted versus rejected. Fix the rejected rows — correct the address, adjust the amount, switch the network the destination can actually receive — and re-run the entire file. The accepted lines are no-ops on the second pass because their keys already exist; only the corrected lines execute.
Distinguish a rejection from a transport timeout, because they call for different reactions. A rejection is a definitive 'no' with a reason — fix and resubmit. A timeout is ambiguous: the request may have landed even though you never saw the response. With idempotency keys, the ambiguous case is also safe to retry — resubmitting the same key either creates the payout (if the first attempt never landed) or returns the existing one (if it did). The key turns 'I don't know if that went through' into a non-problem.
| Outcome | What it means | What you do |
|---|---|---|
| Accepted | The payout was created and is staged for approval. | Record it against the recipient; do nothing on a re-run (key already exists). |
| Rejected (validation) | Bad address, dust-threshold amount, or unreceivable currency on that line. | Fix the row and re-run the whole file; only corrected lines execute. |
| Timed out | Ambiguous — the request may or may not have landed. | Re-run with the same key; you create it once or get the existing one back. |
Step 5 — Release for approval before money moves
Submitting the loop does not move funds. Each payout you create enters a pending-approval state and is released from the dashboard, so the batch you POST is a proposal, not an irreversible action. This is deliberate: it keeps the high-throughput programmatic path — your loop staging thousands of payouts unattended — separate from the human control point that signs them off before anything leaves your balance.
Use that gap. Between staging and approval is the moment to sanity-check the run against your own totals: does the count of accepted payouts match the count of recipients you intended to pay, and does the sum per currency match what your ledger expected to disburse? Catching a mismatch here — a duplicated source row, a fat-fingered amount — is cheap. Catching it after approval is a refund problem. Once the totals reconcile, a treasurer approves and the payouts execute.
Step 6 — Reconcile from signed webhooks, not by polling
A released payout is not done the instant it is broadcast — it has to settle on-chain. halfin uses per-chain confirmation thresholds and reorg-aware crediting, so a payout reported complete has actually settled to the depth that chain requires, not merely been submitted. The signal that a payout reached its terminal state is a webhook, and that is what you reconcile against — not a polling loop that hammers the API asking 'done yet?'.
Two events close the loop: payout.completed fires when a payout settles, and payout.failed fires when one cannot. For each, verify the HMAC signature over the raw request bytes with a constant-time compare before you act — an unsigned or mismatched request is not a halfin event and must never update your ledger. Then look up the payout by its identifier, mark it settled or failed in your records, and notify the payee if you do that. Keep the handler idempotent: a webhook can be redelivered, and a redelivered payout.completed must not mark the same payout settled twice or fire a second notification.
A failed payout is an operational outcome, not a dead end. Investigate the reason — an address that became invalid, an insufficient balance at release — fix the underlying issue, and re-run the line with the same idempotency key if it never settled, or a new key if you are issuing a genuinely different payment. The full event schema lives at docs.thehalfin.com; the discipline that matters is that your reconciliation is driven by signed, verified events, so the per-recipient status in your books means 'settled', not 'we think so'.
- Reconcile on payout.completed and payout.failed — do not poll the API in a loop.
- Verify the HMAC signature over the raw bytes (constant-time) before acting on any event.
- Dedupe on the event so a redelivered payout.completed settles a payout exactly once.
- Re-run a failed line with the same key if it never settled; a new key only for a genuinely new payment.