Batch Payouts
Up to 1,000 payouts in one request: validate every line, then confirm the run.
In plain EnglishSend a whole payroll file or settlement run at once. We check every line first and tell you which ones are wrong; nothing is priced or sent until you confirm. Each line is exactly a single-payout body, so anything you can send alone you can send in a run.
Up to 1,000 payouts in one request: a payroll file, a settlement cycle, a
marketplace disbursement run. Each line of items is exactly a
POST /payouts body, so anything you can send alone you can send in a run.
The batch validates every line first (ownership, routing, capability; nothing
is priced or debited) and only then turns the valid lines into ordinary
payouts. A file with one bad row is the normal case in payroll, not the edge
case, which is why the flow is validate-then-confirm rather than
fire-and-forget.
Before you start: enablement and limits
Batches are on by default, in sandbox and live, bounded by the same caps,
balance holds and approvals as a single payout. An organization can ask us to
opt out, in which case POST .../payouts/batches and
POST .../batches/{batchId}/confirm answer 403 MASS_PAYOUTS_DISABLED and
nothing is submitted; single payouts are unaffected, and your test-mode
organization inherits the setting from the live one. Cancel and every read
work either way, so a run already in flight can always be stopped and
reconciled.
| Route | Limit |
|---|---|
POST .../payouts/batches | 30 per minute |
GET .../batches, GET .../batches/{batchId}, GET .../batches/{batchId}/items | 600 per minute each |
| Lines per batch | 1 to 1,000 |
The submit limit is deliberately low: one request is up to 1,000 payouts, and
30 a minute is the ceiling a stolen write key runs into. A bigger payroll
splits into batches, each with its own Idempotency-Key and its own
externalReferenceId. Over the limit you get 429 with a Retry-After
header in seconds (plus X-RateLimit-Limit, X-RateLimit-Remaining and
X-RateLimit-Reset, on the 429 only). Wait it out and send the same request
again with the same Idempotency-Key; a throttled request never reached
the batch, so there is nothing to dedupe against.
The flow
- Submit the run.
202means received, not paid. - Poll
GET .../batches/{batchId}or take thepayout_batch.*webhooks. - If the batch holds at
awaiting_confirmation, review the invalid lines. - Confirm to proceed with the valid lines, or cancel to stop the run.
- Join the created lines to the payout ledger; each carries a
payoutId.
1. Submit the run
POST /payments/organizations/{orgId}/payouts/batches
x-api-key: avvio_live_…
Idempotency-Key: payroll-2026-09-01-run1
Content-Type: application/json
{
"externalReferenceId": "payroll-2026-09-01",
"autoCommit": true,
"items": [
{ "amount": "200.00", "destinationAccountId": "sbx_acct_MXN_4471_ae66cbc5", "reference": "PAYROLL-2026-0042" },
{ "amount": "150.00", "destinationAccountId": "sbx_acct_MXN_0002_1b02c77d", "reference": "PAYROLL-2026-0043" }
]
}items is 1 to 1,000 payout instructions, in order. Every line is validated
as a whole POST /payouts body, and an unknown field on any line refuses the
whole request with 400, never a silent drop.
externalReferenceId is required, and it IS a uniqueness key
externalReferenceId is required, and it IS a uniqueness keyexternalReferenceId is your own run id: the payroll file name, the cycle id.
It is required, 1 to 128 characters of letters, digits, spaces and
. _ : -, and it is unique per organization. Submitting a second batch
with the same id answers:
// 409
{
"type": "PAYOUT_BATCH_DUPLICATE_REFERENCE",
"message": "A batch with externalReferenceId \"payroll-2026-09-01\" already exists (cmf3k2xg00009q8b7v0w2x4yz). NOTHING WAS SUBMITTED. …",
"originalBatchId": "cmf3k2xg00009q8b7v0w2x4yz"
}Nothing was submitted. If this is a retry, read originalBatchId and carry
on from there. If it is genuinely a new run (corrected lines, a second cycle
the same day), it needs its own id: payroll-2026-09-01-r2, not the id of the
run it corrects. GET .../batches?externalReferenceId= finds the run an id
already names.
Why required rather than optional: the Idempotency-Key only protects a retry
that reuses the key. A submit job that crashes, loses its key store, and
re-runs the same file under a fresh key after the 15-minute near-duplicate
window is a second payroll, and an optional run id would have been left blank
by exactly the integration that needed it.
The Idempotency-Key covers the RUN
Idempotency-Key covers the RUNThe two identifiers guard different things, and you need both:
Idempotency-Keyis this HTTP attempt. Same key, same body, within 7
days: you get the same batch back, never a second payroll. Different body
under the same key:409. This is the batch version of the rule in
Idempotency: persist the key before you send, and reuse it on
every retry.externalReferenceIdis this payroll run, forever. Whatever key the
request arrives under, the run id names the batch that already carries it.
The submit also accepts X-Allow-Duplicate: true, with the same meaning as on
a single payout: only set it when you deliberately mean to repeat a
byte-identical run under a different key. Even then the run id must differ, or
the repeat is the 409 above.
autoCommit
autoCommittrue(the default): a run with zero validation errors proceeds straight
to creation. Any errors found, and the batch holds at
awaiting_confirmation.false: the batch always holds atawaiting_confirmation, even when every
line is valid, for when a human signs off on the run regardless.
When your organization requires approval on API payouts, autoCommit is
forced off: every run holds at awaiting_confirmation, whatever you sent, and
confirm is where the approval is captured (below).
2. Watch it move
GET /payments/organizations/{orgId}/payouts/batches/{batchId}{
"batchId": "cmf3k2xg00009q8b7v0w2x4yz",
"externalReferenceId": "payroll-2026-09-01",
"status": "awaiting_confirmation",
"autoCommit": true,
"counts": { "received": 0, "invalid": 3, "validated": 247, "creating": 0,
"created": 0, "create_failed": 0, "canceled": 0, "requires_review": 0 },
"estimatedSourceTotal": "49400.00",
"createdAt": "2026-09-01T14:03:11.000Z",
"updatedAt": "2026-09-01T14:03:40.000Z",
"completedAt": null
}estimatedSourceTotal is advisory: the sum of the valid lines' source amounts,
set when validation finishes, for you to compare against your balance before
confirming. It is null while validation runs and whenever any line locks the
destination side (amountLeg: "destination"), because pricing it there would
promise a figure creation will not honour. error appears only on a failed
batch.
The batch moves through its own vocabulary, see
Payout Status:
received → validating → (awaiting_confirmation | creating) → completed
canceled · failed (terminal)A batch tracks CREATION, not settlement. completed means every line
either became a payout or was refused, and the counts say which. Collapsing
settlement into the batch would make a 1,000-line run's status a function of
1,000 bank legs, a number that never settles. Once a line is created, watch
the payout, not the item.
failed is the run itself breaking, not a line: error carries
VALIDATION_RUN_FAILED, no payout exists, and the fix is to contact support
with the batchId rather than resubmit blind.
Rather than polling, take the webhooks: payout_batch.awaiting_confirmation,
payout_batch.completed, payout_batch.canceled, payout_batch.failed, same
signing and retry ladder as payout.*. There is deliberately no
payout_batch.creating: between confirmation and completion the interesting
facts are per-payout, and those already arrive as payout.* events. See
Webhooks.
Your runs, newest first:
GET /payments/organizations/{orgId}/payouts/batches?status=&externalReferenceId=&limit=&cursor=limit is 1 to 100 and defaults to 50. Pass the nextCursor back as
cursor; a cursor we did not issue is a 400, never an empty page, because a
silently empty page is how a reconciler concludes a run does not exist. An
unknown status is a 400 for the same reason.
3. Review the lines
GET /payments/organizations/{orgId}/payouts/batches/{batchId}/items?status=invalidEvery line comes back in submitted order with the instruction you sent
echoed back verbatim, so you join errors to your own file by content rather
than by counting rows:
{
"data": [
{
"index": 17,
"status": "invalid",
"instruction": { "amount": "200.00", "destinationAccountId": "sbx_acct_MXN_0009_d41d8cd9", "reference": "PAYROLL-2026-0059" },
"errors": [ { "code": "DESTINATION_ACCOUNT_NOT_FOUND", "message": "…" } ]
}
],
"hasMore": false,
"nextCursor": null
}limit is 1 to 1,000 and defaults to 100. The items cursor is the last line's
index (a number, unlike the batch-list cursor, which is a batch id); pass
nextCursor back as cursor either way. Error codes are the same vocabulary a
single POST /payouts refuses with, so a line failing in a batch reads
identically to the same instruction failing alone; see Errors.
?format=csv returns the whole run (up to 1,000 lines, honouring ?status=)
as a file instead of a JSON page, for the person reviewing the run in a
spreadsheet. Columns: index, status, payoutId, amount, amountLeg, destinationAccountId, reference, errorCode, errorMessage. Only the first error
per line is in the CSV; the full list stays on the JSON.
Item statuses
| Status | Meaning | Terminal |
|---|---|---|
received | Echoed and stored. Not yet examined | |
invalid | Failed validation; errors says why. No payout exists | ✓ |
validated | Passed validation, waiting for creation (or for your confirm) | |
creating | Claimed by the processor; a quote/accept is in flight | |
created | A payout exists. payoutId is set; watch the payout, not the item | ✓ |
create_failed | Refused at creation time; errors says why. No payout exists | ✓ |
canceled | The batch was cancelled before this line was attempted | ✓ |
requires_review | Outcome unknown. Never auto-retried, see below | ✓ |
requires_review: read this one
requires_review: read this oneA line the processor had claimed when the process died, or whose accept the
payment network never answered, has an unknown outcome: the payout may or
may not exist, and re-running it is how a crash becomes a double payment. So
it is never retried automatically, and it is never silently folded into
create_failed; the count is honest so your reconciliation can be. Its
errors carry OUTCOME_UNKNOWN. Contact support with the batchId; we
resolve each one by hand and the line moves to its real outcome. A batch with
a requires_review line still reaches completed once every other line is
resolved, so check the count, not just the status.
4. Confirm or cancel
Both require an Idempotency-Key, and both are only meaningful before the run
is committed:
POST /payments/organizations/{orgId}/payouts/batches/{batchId}/confirm
Idempotency-Key: <a uuid>Only legal while the batch is awaiting_confirmation. The valid lines go to
creation; invalid lines stay refused, so correct them and resubmit as a new
batch with a new externalReferenceId. Anything else answers
409 PAYOUT_BATCH_NOT_CONFIRMABLE, naming where the batch actually is; a
double-clicked confirm reads as the no-op it is. Confirm is gated like submit:
if mass_payouts is switched off while a run is held, confirm answers
403 MASS_PAYOUTS_DISABLED and the run stays held.
When your organization requires approval on API payouts, the first confirm
answers 202 with one approval for the whole run — never one per line:
{ "status": "pending_approval", "approvalId": "cmf3k2xf90008q8b7q4r6s8tu", "requiredApprovals": 2, "expiresAt": "…" }Nothing is created until your approvers reach quorum in the dashboard; then
the run is released within a minute, and the lines are created without
re-entering the approval gate. A repeat confirm returns the same approval. If
an approver rejected it, confirm answers 409 PAYOUT_BATCH_AWAITING_APPROVAL
with the approvalId; cancel still works. Track it at
GET .../payouts/approvals/{approvalId} or through the payout_approval.*
events.
POST /payments/organizations/{orgId}/payouts/batches/{batchId}/cancel
Idempotency-Key: <a uuid>Honoured while the batch is received, validating, or
awaiting_confirmation, before creation starts, so no payout ever exists
from a canceled batch. Lines not yet attempted move to canceled; invalid
lines keep their errors. Once creation begins the run is committed and cancel
answers 409 PAYOUT_BATCH_NOT_CANCELABLE; from there, cancel individual
payouts while they are still pending via
POST .../payouts/{payoutId}/cancel. Cancel is never feature-gated: stopping a
run must always work.
5. Join the run to your ledger
GET /payments/organizations/{orgId}/payouts/batches/{batchId}/items?status=createdEach created line carries the payoutId. From that moment the payout is
indistinguishable from one sent alone: it appears in GET /orders, emits
payout.* webhooks and event-feed rows, and can still fail or be returned by
the receiving bank days later, see Payout Status. Reconcile the
payouts through the event feed, exactly as you would without
batches; the batch itself only answers "did every line become a payout?".
Updated 9 days ago