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.
- Creating a batch pays nobody. The batch waits in
Draftuntil a Kwiikpay operator approves and executes it. No route on this API advances it, and no webhook tells you when it moved. - 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 statusRejected— with200andsuccess: 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,
"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 sum of all
items, skipped ones included, so it is not the amount that will leave the account.
The lifecycle
Draft ──(operator submits)──▶ PendingApproval ──(a different operator approves)──▶ Approved
│ │ │
│ every item skipped │ operator rejects ▼ operator executes
▼ ▼ Executing
Rejected Rejected │
┌───────────────────┼───────────────────┐
▼ ▼ ▼
Completed PartiallyCompleted Failed
- Your create call produces
Draft— orRejectedwhen every item was skipped. - No route on this API submits, approves or executes a batch. Those are Kwiikpay operator actions, and approval is four-eyes: the operator who approves must be a different person from the one who created or submitted the batch.
- Execution pays each
Pendingitem, insequenceorder, as an ordinary fiat withdrawal from the customer's balance, and then settles the batch status:Completedwhen every non-skipped item completed,Failedwhen none did,PartiallyCompletedfor a mix. - There is no webhook for any payout-batch transition. To learn that a batch was approved,
executed or rejected, poll
GET .../payout-batches/{batchId}and readstatus. - There is no turnaround commitment published for operator approval. Ask your Kwiikpay contact how batches are handled for your account before building a flow that depends on timing.
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. |
Completed |
terminal | Every non-skipped item reached Completed. completed_at is set. |
PartiallyCompleted |
terminal | Some items completed, some failed. |
Failed |
terminal | No item completed. |
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 does not mean
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, or a concurrent run owns it. It does not resolve on its own, and a batch holding an Executing item never reaches a terminal status. Contact support with the batch id. |
Completed |
A fiat withdrawal was created for this item. |
Failed |
The withdrawal was refused or errored. failure_code says which. |
Completed means the withdrawal exists — not that the money has settled. The withdrawal may
still be pending at the banking provider or parked for manual review, and the batch item never
changes again once it is Completed. To follow the money, take the item's fiat_payment_id and
find that id in GET /api/v1/partner/customers/{customerId}/banking/withdrawals; the
withdrawal's own status is where settlement and any later failure are reported.
Failure codes
failure_code is null on Pending, Executing and Completed items. Treat the list as open.
Nothing 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. |
withdrawal_failed |
Execution: the provider refused the payment before accepting it. Funds were never taken. failure_reason carries the provider's message. |
Depends on the reason (see the rail section below for the most common one). |
withdrawal_error |
Execution: the submission threw — a compliance block, a limit, a provider fault. failure_reason is the raw error text and its wording is not stable. |
Depends on the cause. |
Branch on failure_code, never on failure_reason.
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 Fiat Republic's 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, Fiat
Republic'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 — Fiat Republic's 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 Fiat Republic's 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:
- Within 24 hours, identical request: the original response is replayed byte for byte — same
body, same message
"Payout batch created successfully". The endpoint does not run again. - Within 24 hours, different body, path or caller:
409 idempotency_key_reused. If the first attempt is still running you get409 idempotency_key_in_progressinstead — retry the identical request shortly. - After 24 hours, identical request: the endpoint runs again, finds the batch by key and returns
it in its current state, with the message
"Payout batch retrieved successfully". That message is the only signal that this call did not create the batch — there is no 201 and no header.
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.
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 |
|---|---|---|---|---|---|
| Just created | Draft |
3 | 0 | 1 | 16500 |
| Approved | Approved |
3 | 0 | 1 | 16500 |
| Executed, item 2 refused by the provider | PartiallyCompleted |
3 | 1 | 2 | 16500 |
item_countcounts every item, skipped ones included.success_countcounts items inCompletedonly, so it is always 0 before execution.failure_countcountsFailedandSkippedtogether.total_amountnever changes: it is the sum of every item's amount as recorded.
The amount that actually left the account in the last row is the sum of the Completed items
(12500), which you compute from items — no field on the batch reports it.
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. |
| 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 (Fiat Republic'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. |