/ Payouts v1 Dashboard ↗

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#

  1. Submit the run. 202 means received, not paid.
  2. Poll GET .../batches/{batchId} or take the payout_batch.* webhooks.
  3. If the batch holds at awaiting_confirmation, review the invalid lines.
  4. Confirm to proceed with the valid lines, or cancel to stop the run.
  5. Join the created lines to the payout ledger; each carries a payoutId.

1. Submit the run#

http
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:

jsonc
// 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:

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#

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#

http
GET /payments/organizations/{orgId}/payouts/batches/{batchId}
jsonc
{
  "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:

text
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:

http
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#

http
GET /payments/organizations/{orgId}/payouts/batches/{batchId}/items?status=invalid

Every 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:

jsonc
{
  "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:

http
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:

json
{ "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.

http
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#

http
GET /payments/organizations/{orgId}/payouts/batches/{batchId}/items?status=created

Each 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?".