Documentation
Send a payout batch
Dispatch thousands of legs in one request and handle partial failures.
A payout batch sends many transfers under one request, one approval, and one idempotency key. This guide covers constructing, submitting, and recovering a batch.
Build the batch#
Batches accept up to 25,000 legs. Each leg names its own recipient, amount, and optionally its own chain and asset.
const batch = await juicepay.payouts.createBatch(
{
reference: `payroll-${run.period}`,
funding: { asset: "USDC", source: "auto" },
routing: {
prefer_chains: ["base", "solana"],
allow_bridging: true,
max_network_fee: "1.25",
},
legs: run.recipients.map((recipient, row) => ({
row,
amount: recipient.net.toFixed(2),
asset: recipient.preferredAsset ?? "USDC",
recipient: recipient.walletId,
reference: `payroll-${run.period}-${recipient.employeeId}`,
})),
},
{ idempotencyKey: `payroll:${run.period}` },
);Two details carry most of the weight:
rowis echoed back in every event, so you can map results to source rows without depending on array order.- Per-leg
referencemakes your own reconciliation trivial and gives support staff something human-readable to search.
Validate before you submit#
Dry runs validate every leg without moving value.
curl -s -X POST "$JUICEPAY_URL/v1/payouts/batches?dry_run=true" \
-H "Authorization: Bearer $JUICEPAY_KEY" \
-H "Idempotency-Key: payroll-2026-08-19-run-2-dry" \
-d @batch.json | jq '.legs[] | select(.valid == false)'Validation catches malformed addresses, recipients failing sanctions screening, legs that would breach policy limits, and insufficient balance at the batch total.
Watch the batch progress#
A batch moves through created → authorising → executing → settled, or to partially_settled if some legs fail permanently.
curl -s "$JUICEPAY_URL/v1/payouts/batches/bt_8kL3mQ" \
-H "Authorization: Bearer $JUICEPAY_KEY" | jq '.status, .counts'"partially_settled"
{
"total": 10000,
"settled": 9987,
"failed": 3,
"pending": 10
}Legs retry automatically with backoff. A leg only reaches failed once the retries are exhausted or the error is classified as permanent — an invalid address, a blocked recipient, or an insufficient balance that you have not topped up.
Recover the stragglers#
Query the failures, fix the cause, and replay only those legs.
const failed = await juicepay.payouts.listLegs(batch.id, { status: "failed" });
const repaired = failed.data.map((leg) => ({
...leg,
recipient: correctedRecipientFor(leg.reference),
}));
await juicepay.payouts.replayLegs(batch.id, {
legs: repaired.map((leg) => ({ row: leg.row, recipient: leg.recipient })),
});Replaying a leg that already settled is a no-op, because the leg key is derived from the batch key and the row index. That is what makes it safe to replay a slightly stale failure list.
Failure classification#
| Error code | Class | What to do |
|---|---|---|
recipient_invalid_address | Permanent | Fix the destination and replay the leg. |
recipient_screening_blocked | Permanent | Escalate to compliance; do not replay. |
insufficient_balance | Transient | Top up and replay. |
network_congested | Transient | Leave it; retries handle it. |
chain_halted | Transient | Retries resume when the network resumes. |
routing_unavailable | Transient | Relax prefer_chains or enable bridging. |
Treating a permanent error as transient is how batches loop forever. Treating a transient error as permanent is how payroll ends up short. Classify deliberately.
Approval flow#
If your account requires approvals, the batch pauses at authorising. Approvers see the total, the recipient count, and any legs that tripped a warning. Approving commits the batch; rejecting returns it to created with your reason attached.
Approval windows are configurable, and an unapproved batch expires rather than sitting indefinitely — a stale payroll batch is a liability.
Reporting#
curl -s "$JUICEPAY_URL/v1/payouts/batches/bt_8kL3mQ/report.csv" \
-H "Authorization: Bearer $JUICEPAY_KEY" -o payroll.csvThe CSV includes one row per leg with the final status, chain, transaction hash, network fee, and your reference — enough to close the run without touching the API again.