Batch Payouts
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 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#
The 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#
true(the default): a run with zero validation errors proceeds straight to creation. Any errors found, and the batch holds atawaiting_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#
A 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?".