Why payroll is harder than a one-off send
A single payout is easy to reason about: one destination, one amount, you watch it settle. Payroll is the opposite shape — dozens or hundreds of destinations, each with its own amount, repeated on a cadence, against a roster that drifts as people join and leave. The hard part isn't the transfers; it's keeping the run safe to interrupt. If your payroll script dies partway through, the question "who has already been paid this cycle?" has to have a definite answer, or someone gets paid twice and someone else gets skipped.
The expensive failure is the silent double-pay on a known recipient. A contractor's transfer times out ambiguously — the transaction may already be on-chain even though your client never got a response — and a naive re-run pushes a second payment to a person who already received this cycle's pay. Unlike an anonymous one-off, payroll recipients are recurring and named, so a duplicate is both a real loss and a relationship problem you hear about directly.
halfin removes the ambiguity by making each payroll line idempotent. Derive the key from the thing that uniquely identifies a payment in your books — the worker's account id joined with the pay period — and re-submitting the run returns the payout halfin already created instead of making a new one. The cycle becomes safe to re-run from the top after any crash, restart, or timeout, which is the one property a hand-rolled payroll loop never reliably has.
A pay cycle is one batch you stage, then approve
Model each pay period as a batch built from your own roster. Every line names a currency, an amount as a string, a destination address, and a deterministic idempotency key. Because the key is reproducible, re-running the same period reproduces the same keys, and halfin matches each one to the payout it already holds — so a retry converges on exactly one payment per person rather than a second round of pay.
Staging a run is not paying it. Each payout enters a pending-approval state and is released from the dashboard, so the batch your integration POSTs is a proposal a human still has to sign off. For payroll that separation is the whole point: your system can assemble the cycle unattended from HR or contractor data, and a finance operator reviews the total and the line count before any funds leave the balance. Nobody's pay goes out because a cron job fired at the wrong time.
Roster changes are absorbed line by line. A contractor who left has no line this cycle; a new hire gets a fresh line with a fresh key. If a line is rejected up front — a malformed address, an amount below a chain's dust threshold, an asset the destination can't receive — only that line fails and is reported back. You fix the offending rows and re-submit the whole period: the lines that already succeeded are no-ops, and only the corrected ones execute. One bad row never blocks the rest of the run.
What a payroll line carries
Every person in the run is described by the same small set of fields. Amounts are always strings, never floating-point numbers, so a contractor's pay is transported exactly with no binary-rounding drift between your ledger and the chain. Currency and network are chosen per line, so the same cycle can pay people on the rails they actually use — a contractor in USDT on Tron, a staff member in USDC on Base, a builder in native SOL.
| Field | Type | Notes |
|---|---|---|
| currency | string | Asset + network, e.g. USDT (TRC-20 / ERC-20 / Solana), USDC (ERC-20 / Solana / Base), BTC, ETH, SOL. |
| amount | string | Each person's pay as a decimal string. Never a JS number — payroll math stays exact end to end. |
| destination | string | The worker's address; validated for the chosen network before the line is accepted. |
| idempotency_key | string | Deterministic per worker + pay period, e.g. account id joined with the cycle. Re-submitting returns the original payout. |
Running a pay cycle from code
There is no dedicated payroll endpoint to learn. A pay run is a fan-out over the single-payout API, where the idempotency key makes the loop safe to interrupt and re-run. Build each key from your own records so the same person in the same period always maps to the same key, then wrap the whole loop in a retry — the second pass is correct by construction, not dangerous.
The example below walks a roster file and submits each worker's line with its own key built from the account id and the pay period. The only headers a request carries are `X-API-Key` and `Content-Type`; the idempotency key is a snake_case field in the request body, not a header. After the run is staged, an operator releases it from the dashboard before anything settles.
# Stage a pay cycle; each line is idempotent, so re-running after a crash is safe.
PERIOD=2026-06
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_API_KEY" \
-d "{
\"currency\": \"$currency\",
\"amount\": \"$amount\",
\"destination\": \"$destination\",
\"idempotency_key\": \"payroll-$PERIOD-$account\"
}"
done < roster.csv
# Re-run the same period after a failure: keys collide, nobody is paid twice.
# Then a finance operator approves the staged run in the dashboard before funds move.Funding the run and keeping the books straight
Payroll has to be paid from a balance that actually holds the right asset on the right chain. A USDT-on-Tron payroll line draws on a USDT-on-Tron balance; a USDC-on-Base line draws on USDC on Base. When your treasury is sitting in one asset but the cycle pays in another, balance conversion is the bridge — convert asset-to-asset before you stage the run so each line has funds behind it. Balance conversion is treasury rebalancing between crypto assets, not a fiat off-ramp, and it does not pay anyone on its own; it just makes sure the right asset is available when the run is approved.
Because every line carries a deterministic key tied to a worker and a period, the run reconciles cleanly against your own payroll ledger. Each person maps to exactly one payout per cycle, partial failures are reported per line, and the approval step gives finance a single point to confirm the total before release. Webhooks close the loop after approval: a `payout.completed` event tells you a worker's pay has settled, and `payout.failed` flags a line that needs attention — verify the HMAC signature on the event before you act on it.
- Per-line network choice — pay contractors on USDT/Tron, staff on USDC/Base or Ethereum, builders on native SOL, in one run.
- Operator approval before any funds leave the balance — the staged cycle is a proposal, not an instant transfer.
- Deterministic keys keep the cycle idempotent across crashes, restarts, and re-runs — one payment per person.
- Settlement is reorg-aware with per-chain confirmation thresholds, so a line reporting complete has actually settled.
- `payout.completed` / `payout.failed` webhooks (HMAC-signed) drive your own payroll status without polling.
Recurring pay without saved cards or stored credentials
Payroll is recurring by nature, but halfin does not auto-charge or hold anyone's payment instrument. Each cycle is a fresh batch you assemble and approve — the recurrence lives in your scheduler, not in a stored mandate. That keeps the model honest: there is no standing authority to move funds, every run passes through a human approval, and the idempotency keys make the scheduled re-submission safe rather than risky.
The same primitives back a single payout when you only need to pay one person off-cycle — a correction, a final settlement, a one-off bonus — and the mass-payout flow when the whole roster goes out together. Payroll is simply the recurring, roster-shaped case of the same guarantees: stage from your records, approve once, reconcile against a per-person result, and let the keys absorb every retry along the way.