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,
"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
- 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. An item whose payment was created readsSubmittedwhile settlement is open, and the batch settles only when every non-skipped item's money outcome is known:Completedwhen all payments settled,Failedwhen none did,PartiallyCompletedfor a mix. A batch never readsCompletedwhile its payments are still in flight or under review (2026-09-22). - 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, 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:
- 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.
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).
- Within one request: a row that repeats an earlier row is still paid, and its id is listed in
the response's
payout_batch.duplicate_item_ids. Null or empty means no repeats. Review the list before approving; it exists so a duplicated CSV line is visible, not silent. - Across requests: the platform fingerprints each batch's payout set. Creating an identical set
again within 7 days under a fresh
Idempotency-Keyreturns409 duplicate_batchnaming the existing batch, its status and creation time. To repeat deliberately — a payroll rerun — resend the identical payout set with"confirm_duplicate": trueand a newIdempotency-Key. Without that flag the repeat is refused, so re-uploading the same file cannot pay everyone twice. - A key reused with a different payout set is refused: you see the idempotency layer's
409 idempotency_key_reusedinside its 24-hour window, and the batch layer's409 idempotency_key_conflictwhen the same key reaches a batch that already carries a different payout set. Retrieve the original batch; use a new key for a new set.
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 |
item_countcounts every item, skipped ones included.success_countcounts items inCompletedonly — payments that SETTLED. Before execution it is 0; after execution it rises as payments settle, so a partial count means money is still in flight.failure_countcountsFailed,ReturnedandSkippedtogether — everything that did not pay.total_amountis the payable total: every non-skipped item's amount.skipped_amountreports the skipped part (total_amount + skipped_amount= what you submitted), andexecuted_amountis the settled part — the amount that actually left the account in the last row (12500).
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. |