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,
      "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
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_PAYMENTSFPS FPS
EUR SCT SEPA, SEPA_INSTANT, SEPA_CREDIT_TRANSFER, SCT_INSTSCT SCT
USD ACH, ACH_SAME_DAY, DOMESTIC_WIRE, INTERNATIONAL_WIRE WIRE, FEDWIREDOMESTIC_WIRE; SWIFTINTERNATIONAL_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:

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

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.