Payout batches

A payout batch pays many of one customer's stored beneficiaries from that customer's balance in a single call. Read this page before you integrate it: the endpoint is deliberately not a "fire and forget" payment call, and two of its behaviours surprise almost everyone.

  1. Creating a batch pays nobody. The batch waits in Draft until a Kwiikpay operator approves and executes it. No route on this API advances it, and no webhook tells you when it moved.
  2. HTTP 200 does not mean the batch is good. Bad items come back as Skipped, and a batch whose items are all bad is created in status Rejected — with 200 and success: true.

Scopes: payout-batches:read for the two read routes and payout-batches:write for create. These are their own pair — transactions:write does not cover them (see Authentication & scopes).

The three routes

Route What it does
POST /api/v1/partner/customers/{customerId}/payout-batches Validates every item and stores the batch in Draft. Moves no money.
GET /api/v1/partner/customers/{customerId}/payout-batches Pages through the customer's batches, newest first, without items.
GET /api/v1/partner/customers/{customerId}/payout-batches/{batchId} One batch with its items and their outcomes. This is the route you poll.

Creating a batch

curl -X POST https://api.kwiikpay.io/api/v1/partner/customers/{customerId}/payout-batches \
  -H "X-Api-Key: kp_live_..." \
  -H "Idempotency-Key: payroll-2026-09-a1b2c3" \
  -H "Content-Type: application/json" \
  -d '{
    "currency": "GBP",
    "rail": "FPS",
    "items": [
      { "beneficiary_id": "0198e2f4-6b7c-7d3a-9f1e-2c4a5b6d7e8f", "amount": 12500, "reference": "Salary Sep" },
      { "beneficiary_id": "0198e2f4-6b7c-7d3a-9f1e-2c4a5b6d7e90", "amount": 4000 },
      { "beneficiary_id": "0198e2f4-6b7c-7d3a-9f1e-2c4a5b6d7e91", "amount": 0 }
    ]
  }'

Every item names a beneficiary you have already registered for this customer with POST /api/v1/partner/customers/{customerId}/banking/beneficiaries, in the batch currency, that has finished provisioning at the provider and has been approved. amount is in minor units at a scale of 2 for every currency (12500 is 125.00). rail and reference are optional (see below).

The response for the request above:

{
  "success": true,
  "status_code": 200,
  "message": "Payout batch created successfully",
  "data": {
    "payout_batch": {
      "id": "0198e2f4-...",
      "customer_id": "…",
      "currency": "GBP",
      "rail": "FPS",
      "status": "Draft",
      "item_count": 3,
      "success_count": 0,
      "failure_count": 1,
      "total_amount": 16500,
      "executed_amount": 0,
      "skipped_amount": 0,
      "approved_at": null,
      "completed_at": null,
      "created_at": "2026-09-02T10:00:00+00:00",
      "updated_at": "2026-09-02T10:00:00+00:00",
      "items": [
        { "id": "…", "sequence": 1, "beneficiary_id": "…", "amount": 12500, "reference": "Salary Sep", "status": "Pending", "fiat_payment_id": null, "failure_code": null, "failure_reason": null },
        { "id": "…", "sequence": 2, "beneficiary_id": "…", "amount": 4000, "reference": null, "status": "Pending", "fiat_payment_id": null, "failure_code": null, "failure_reason": null },
        { "id": "…", "sequence": 3, "beneficiary_id": "…", "amount": 0, "reference": null, "status": "Skipped", "fiat_payment_id": null, "failure_code": "validation", "failure_reason": "amount must be a positive whole minor-unit value." }
      ]
    }
  }
}

Read the counters carefully: failure_count: 1 on a brand-new Draft batch means one item was refused at intake — nothing has been paid or failed to pay. total_amount is the payable total: the sum of every non-skipped item. Skipped items are excluded and reported as skipped_amount (total_amount + skipped_amount is what you submitted); executed_amount is the part whose payments have settled. On a batch created before 2026-09-22, executed_amount counts that row's legacy Completed items even where the payment behind one did not settle — the legacy caveat in the item-status table below applies to this total too, and fiat_payment_id remains the per-item authoritative check.

The lifecycle

Draft ──(operator submits)──▶ PendingApproval ──(a different operator approves)──▶ Approved
  │                                  │                                                │
  │ every item skipped               │ operator rejects                               ▼ operator executes
  ▼                                  ▼                                            Executing
Rejected                          Rejected                                            │
                                                                  ┌───────────────────┼───────────────────┐
                                                                  ▼                   ▼                   ▼
                                                              Completed       PartiallyCompleted        Failed
Batch status In flight? Meaning
Draft yes Created, validated, waiting for an operator to submit it for approval.
PendingApproval yes Submitted; waiting for a second operator's decision.
Approved yes Approved, not yet executed. approved_at is set.
Executing yes Items are being submitted as withdrawals, and their payment outcomes are being followed.
Completed terminal Every non-skipped item's payment SETTLED. completed_at is set.
PartiallyCompleted terminal Some items settled, some failed or were returned.
Failed terminal No item settled.
Rejected terminal Refused at intake (every item skipped) or by an operator. approved_at and completed_at stay null.

What an item's status means — and what Completed means now

Item status Meaning
Pending Valid at intake; waits for approval and execution.
Skipped Refused at intake. failure_code is validation; failure_reason says why. Terminal — it never executes.
Executing A withdrawal submission was in flight when it was interrupted. The platform re-drives it automatically through the same idempotency key (it can never double-pay) and it normally resolves within minutes; a batch holding one stays in flight until it does.
Submitted The fiat withdrawal was created; its settlement is still open — the provider may be processing it or it may be under manual review. This is not a paid state. Take fiat_payment_id to follow the payment itself.
Completed The payment SETTLED — the beneficiary was paid (money truth as of 2026-09-22).
Returned The payment settled and the receiving bank later sent the money back; the funds are in the balance again. failure_code is withdrawal_returned.
Failed The payment did not go through. failure_code says which class of refusal; no funds were taken.

Before 2026-09-22 an item reading Completed only meant "the withdrawal was created" — a payment parked for manual review could sit behind that word while nothing had been paid. That is fixed: Submitted now carries "created, not yet paid", Completed means the money actually settled, and the platform projects every later payment outcome (settlement, failure, a return) onto the item automatically — a batch that held unresolved payments no longer settles, and an item's status is kept in step with the payment behind it. A row created before 2026-09-22 may still show Completed without a settled payment; its fiat_payment_id is the authoritative check.

Failure codes

failure_code is null on Pending, Executing, Submitted and Completed items. Treat the list as open. Nothing that failed is retried automatically: fix the cause and create a new batch for the affected items.

failure_code When Can a new batch succeed?
validation Intake: bad amount, empty or over-long reference, beneficiary missing / unapproved / still provisioning / wrong currency. Yes, once the item is fixed.
subject_unavailable Execution: the customer no longer exists for your tenant. No.
subject_inactive Execution: the customer's onboarding is no longer Active. Once the customer is active again.
beneficiary_not_payable Execution: the beneficiary changed since intake (currency, provisioning or approval). Once the beneficiary is fixed.
configuration Execution: the customer's account or ledger is not ready for this currency and provider. After Kwiikpay resolves the configuration.
source_account_pot_missing Execution: the source is a linked-business account that has never received a deposit. Yes — pay into the account first.
compliance_blocked Execution: a compliance check blocked this payment. Once Kwiikpay clears the block.
money_movement_frozen Execution: payments are paused for the account while Kwiikpay reviews it. Once the pause is lifted.
withdrawal_failed Execution: the payment did not go through (the provider refused it, or it failed after acceptance). No funds were taken; if they had been, the item would be Returned instead. Depends on the reason (see the rail section below for the most common one).
withdrawal_error Execution: the submission failed internally and its fate is being verified by the platform before anything is retried. Depends on the cause; check the item again shortly.
withdrawal_returned After settlement: the receiving bank sent the money back; the funds are in the balance again. Always paired with status Returned. Yes — after correcting the payee details.

Branch on failure_code, never on failure_reason — the reason is a customer-safe sentence written for display (2026-09-22: provider names and raw error text are never exposed here; the raw text is kept in the audit record for support).

Per-item skipping: what is refused at intake

Each item is checked on its own. A refused item becomes Skipped and the others carry on — the request only fails as a whole for the reasons in the error table at the end.

Problem Item outcome What you get back
amount zero, negative or fractional Skipped The amount you sent is echoed.
reference longer than 256 characters (trimmed) Skipped reference is null (dropped, not truncated). If the amount was also invalid, amount is recorded as 1 and the reason names only the reference.
reference is an empty or blank string Skipped on FPS, CHAPS, SCT and every USD rail ("a payment reference is required.") reference is null. Omit the field or send null to get the default "Batch payout".
reference breaks the rail rule (below) Skipped The reference is kept as sent.
Beneficiary not found for this customer, in another currency, still provisioning, or not approved Skipped The reason names the check that failed.
beneficiary_id omitted or all zeros Skipped The item comes back with beneficiary_id as sent and the reason naming the field.

Because a skipped item can come back with a different amount or a null reference, match response items to your own rows on sequence — the 1-based position in the array you sent — never on amount or reference.

Rails

rail applies to every item. It is validated against the FIAT_REPUBLIC scheme table for the batch currency before anything is stored, and the stored value is the canonical scheme, which is what the response returns. Compare against the canonical value, not against what you sent.

Currency Rails you can send (canonical) Accepted aliases Default when omitted
GBP FPS, CHAPS, BACS FASTER_PAYMENTS → FPS FPS
EUR SCT SEPA, SEPA_INSTANT, SEPA_CREDIT_TRANSFER, SCT_INST → SCT SCT
USD ACH, ACH_SAME_DAY, DOMESTIC_WIRE, INTERNATIONAL_WIRE WIRE, FEDWIRE → DOMESTIC_WIRE; SWIFT → INTERNATIONAL_WIRE ACH

Only the platform currencies (GBP, EUR, USD) reach this table — any other currency is refused at intake with 422 unsupported_currency, before the rail is even looked at.

Case, hyphens and spaces are tolerated. Anything else — including EAGLE_NET_TRANSFER, a provider's internal book transfer — is refused with 422 unsupported_rail. Note that SEPA_INSTANT is accepted only as an alias for SCT: there is no instant SEPA on a batch.

The rail is handed to each beneficiary's provider unchanged at execution. OPENPAYD, which holds some GBP and EUR beneficiaries, accepts FPS and CHAPS but has no case for SCT or BACS. So a EUR batch, or a GBP batch on BACS, whose beneficiary is held at OPENPAYD is refused item by item at execution — after approval — with failure_code: withdrawal_failed. No funds are taken for those items. If your customer's beneficiaries are at OPENPAYD, pay EUR through the single withdrawal route (POST /api/v1/partner/customers/{customerId}/banking/withdrawals), which translates rails per provider; batches do not.

References

The reference is the text the recipient sees on their statement. Omit it (or send null) and the item is paid with "Batch payout". When you do send one it is checked at intake against the stored rail:

Stored rail Checked at intake? Limit Character rule
FPS yes 18 ASCII bytes ASCII only
CHAPS yes 35 ASCII bytes ASCII only
SCT yes 140 ASCII bytes ASCII only
ACH, ACH_SAME_DAY, DOMESTIC_WIRE, INTERNATIONAL_WIRE yes — the FIAT_REPUBLIC rule 17 counted characters (space, -, ., &, / are not counted) letters a-z A-Z plus those five characters; at least 6 alphanumerics, not all the same
BACS no — none locally; the provider decides at execution

Two limits always apply: an empty or blank string is refused wherever a check runs, and a reference over 256 characters (after trimming) is refused on every rail. Kwiikpay does not re-check an unchecked reference before submission — for BACS the provider is the only authority, and its refusal would surface as a Failed item after approval.

Currency

currency must be a platform fiat currency — GBP, EUR or USD — and is matched against each beneficiary's own currency. This route applies the same platform-currency gate as the single withdrawal and beneficiary routes: anything else, AUD included, is refused with 422 unsupported_currency and no batch is created. (Before 2026-09-02 a non-platform currency such as AUD could pass intake on the strength of the FIAT_REPUBLIC scheme table and then reject every item; that trap is closed.)

Size limit

A batch holds 1 to 500 items. Zero items is 422 items_required; more than 500 is 422 too_many_items. Split larger runs into several batches, each with its own Idempotency-Key.

Idempotency on this route

Idempotency-Key is required. What a retry returns depends on when you send it:

Use one key per batch you intend to create, derived from something inherent to the run (a payroll id, a file hash), and keep it with your record of the batch. The general rules are in Idempotency.

Duplicates and exact repeats

Two protections sit on top of the idempotency rules above. Both compare the exact tuple beneficiary + amount + reference (plus, across requests, the customer, currency, rail and transparency).

Listing and reading batches

GET /api/v1/partner/customers/{customerId}/payout-batches returns the customer's batches newest first (created_at, then id, descending) with items set to null on every entry. limit is clamped to 1..500 (default 100; 0 or a negative value becomes 1) and page is 1-based. pagination.total is the real number of batches for the customer and pagination.last_page is ceil(total / per_page) — a customer with 150 batches and the default limit needs two pages. There is no status or date filter.

GET /api/v1/partner/customers/{customerId}/payout-batches/{batchId} returns one batch with its items in sequence order. Poll it to observe transitions.

Both read routes can return two different 404s, both as a flat error object:

title Meaning
Customer was not found. The customer id is unknown — or your key is bound to a different customer. A subject-bound key gets 404 here, not 403.
Payout batch was not found. (Read-by-id only.) The batch id is unknown — or the batch belongs to a different customer of your tenant. The two cases are deliberately indistinguishable.

Counters, in one worked example

Three items are sent; item 3 has amount 0.

Moment status item_count success_count failure_count total_amount executed_amount skipped_amount
Just created Draft 3 0 1 16500 0 0
Approved Approved 3 0 1 16500 0 0
Executed, item 2 refused by the provider PartiallyCompleted 3 1 2 16500 12500 0

Errors by route

POST .../payout-batches

Status code Body shape Condition Fix
400 idempotency_key_invalid application/problem+json Idempotency-Key missing, sent twice, or not 8–256 visible ASCII characters. Send exactly one valid header.
401 api_key_authentication_failed application/problem+json Missing or invalid API key. See Authentication & scopes.
403 api_key_not_authorized application/problem+json The key lacks payout-batches:write. Request the scope by name.
404 — flat error, title Customer was not found. Unknown customer id, or a customer your key is not bound to. Check the id and the key's binding.
409 idempotency_key_reused application/problem+json Same key, different body, path or caller. Use a new key for a new batch.
409 idempotency_key_in_progress application/problem+json The first attempt with this key is still running. Retry the identical request shortly.
409 duplicate_batch envelope, data.code The identical payout set was already created for this customer within the last 7 days. Resend with "confirm_duplicate": true and a new Idempotency-Key if the repeat is deliberate, or reuse the existing batch.
409 idempotency_key_conflict envelope, data.code This Idempotency-Key already belongs to a different or legacy batch with a different payout set. Retrieve the original batch; use a new key for a new set.
422 currency_required envelope, data.code currency blank or missing. Send GBP, EUR or USD.
422 items_required envelope, data.code items missing or empty. Send 1–500 items.
422 too_many_items envelope, data.code More than 500 items. Split the run.
422 unsupported_currency envelope, data.code currency is not a platform fiat currency (GBP, EUR, USD) — AUD included. Use an offered currency.
422 unsupported_rail envelope, data.code Rail is not a scheme for this currency, or is EAGLE_NET_TRANSFER (a provider's internal book transfer). data.detail lists what is allowed. Use a rail from the table above.
422 subject_not_active envelope, data.code The customer is not Active (onboarding not approved, suspended, closed). Complete onboarding first.
429 rate_limited application/problem+json Rate limit exceeded. Honour Retry-After.

GET .../payout-batches and `GET .../payout-batches/

Status Body shape Condition Fix
401 / 403 / 429 application/problem+json As above; the scope here is payout-batches:read. As above.
404 flat error, title Customer was not found. Unknown customer, or a customer your key is not bound to. Check the id and the binding.
404 flat error, title Payout batch was not found. (By id only.) Unknown batch, or another customer's batch. Check the id against the list route for this customer.