Beneficiaries

A beneficiary is an external bank account a customer can withdraw to. You register it once, wait for it to become payable, then reference its id on every withdrawal. This page covers the four things the reference pages cannot say in one place: what to send for each currency, how to read the response (HTTP 200 does not mean the payee exists), the approval gate, and what update and delete really do.

Routes (all under /api/v1/partner/customers/{customerId}/banking/beneficiaries):

Call What it does
POST .../beneficiaries Registers a payee. Returns an array — see Reading the response.
GET .../beneficiaries/{beneficiaryId} Reads one payee, including approval_status. Poll this.
GET .../beneficiaries Lists every payee the customer ever had, deleted ones included.
PATCH .../beneficiaries/{beneficiaryId} Replaces the payee with a new one — not a partial update.
DELETE .../beneficiaries/{beneficiaryId} Disables the payee locally. Nothing is removed at the bank.

Before you can create one

A beneficiary lives at a banking provider (Fiat Republic or OpenPayd), and it can only be created at a provider where the customer already holds a fiat account in the same currency that the provider has accepted — the account's provider_account_id is set. There is no provider field to choose from: the customer's accounts decide.

So the order is fixed:

  1. POST .../banking/accounts in the currency, then poll GET .../banking/accounts/{accountId} until provider_account_id is set (status Completed is what you want before withdrawing).
  2. POST .../beneficiaries in that currency.
  3. GET .../beneficiaries/{beneficiaryId} until status is Completed and approval_status is Approved. For OpenPayd registrations, also wait for bank_registration.status to become Registered.
  4. POST .../banking/withdrawals with the beneficiary's id.

Skip step 1 and you get 409 bank_account_required. The provider that holds the account is the provider that will hold the beneficiary, and later the provider whose account funds the withdrawal — see the provider chain.

What to send, per currency and scheme

Send currency (or currency_code), name, the beneficiary's address, and the bank coordinates for the currency. The table says, for each field, whether Kwiikpay checks it before calling the provider — the difference matters because a field that is required but not pre-checked does not produce a 4xx when you omit it (footnote 1).

Legend: R = required and pre-checked, refused with 422 beneficiary_details_incomplete when missing · P = required by the provider but not pre-checked: omitting it returns 200 with status ManualReview · D = silently defaulted when omitted · O = optional · = ignored for this column.

Field GBP · Fiat Republic EUR · Fiat Republic USD · ACH / ACH_SAME_DAY USD · DOMESTIC_WIRE USD · INTERNATIONAL_WIRE GBP / EUR · OpenPayd
currency / currency_code R R R R R R
type (PERSON / BUSINESS; footnotes 1 and 6) D D D D D Explicit business type for the type/name option, otherwise attributes (footnote 6)
name D (footnote 2) D D D D Explicit company name for the type/name option, otherwise attributes (footnote 6)
address_line1 P P P P P O
city P P P P P P for CHAPS, else O
postal_code P P P P P P for CHAPS, else O
state O O R when country is US or CA same same O
country D (footnote 3) D R R R P (defaulted as GBP/EUR)
sort_code P (one bank field, footnote 4) O (footnote 5) (footnote 5) (footnote 5) P for GBP unless valid UK IBAN supplied
account_number P (one bank field) O R (exactly one of iban / account_number) R R unless iban P for GBP unless valid UK IBAN supplied
iban O P (one bank field) not with account_number not with account_number R unless account_number P for EUR; UK IBAN accepted for GBP (footnote 6)
bic O P (one bank field) O O R O
aba_routing_number R, 9 digits, valid check digit O R (ABA of the US correspondent)
wire_routing_number O R, 9 digits, valid check digit O
routing_code_type O (defaults ABA) O (defaults WIRE) O
account_type (CHECKING / SAVINGS) R O O
schemes O (defaults from rail, else ACH) O O
bank_name O O O R R
bank_country or bank_address_country O O O R (either) R (either), must agree with the IBAN country (footnote 7)
bank_address_line1 / _city / _postal_code O O O O R
intermediary_bic_swift O O O O O — but if sent, all intermediary_* become R
rail O (routing hint only) O O (selects the routing-code type) O O O — FPS/CHAPS accept sort code + account number or a valid UK IBAN; SEPA/SEPA_INSTANT need an IBAN
attributes O (footnote 8) O O O O Names, or explicit business type + name (footnote 6)

Footnotes:

  1. The per-scheme field validator runs for USD only. OpenPayd names are also checked before the workflow on every currency. A supplied GBP domestic IBAN and any accompanying provider account/sort fields are checked together (footnote 6). Other malformed GBP/EUR details can still return HTTP 200 with status ManualReview after a provider refusal. Fiat Republic requires an exact uppercase PERSON / BUSINESS type; an invalid type can also park there.
  2. The generic name defaults to the customer's own display name when omitted. This default never supplies OpenPayd's beneficiary identity. Always send the payee's own name.
  3. country is defaulted on GBP and EUR — to the customer's own country, and if that is blank, to GB. A UK customer's German payee is registered with a GB address unless you send it. On USD it is required and never guessed.
  4. At least one bank coordinate. Fiat Republic refuses a payee with none of account_number, iban, bic or a routing code; on GBP/EUR that refusal is a 200 + ManualReview. A GBP payee is sent as sort_code + account_number; a EUR payee as iban (+ bic).
  5. Routing-code precedence. Routing codes are collected in the order sort_code, aba_routing_number, wire_routing_number, typed (sort_code becomes ABA on USD, or WIRE when the only scheme is DOMESTIC_WIRE) and de-duplicated by type, first wins — a sort_code on a USD body silently discards an explicit aba_routing_number. At most two routing codes per payee. Use the explicit fields on USD; never send sort_code with them.
  6. OpenPayd needs a company name or separate personal names. For a company, send type: "BUSINESS" and a nonblank name; these supply beneficiary_company_name on both create and replacement. OpenPayd also accepts COMPANY / CORPORATE here, ignoring case and surrounding spaces. Both type and name must be explicit: customer defaults are never used for this mapping. Explicit attributes.type / attributes.name retain precedence. Alternatively send attributes.beneficiary_company_name directly. For a person, send attributes.beneficiary_first_name and attributes.beneficiary_last_name; a display name alone is not split or guessed. Explicit provider name attributes are never overwritten, including blank or partial values. attributes.beneficiary_type, when supplied, must be RETAIL or CORPORATE; an explicit RETAIL is never converted to a company. Missing or invalid identity is refused at intake with 422 beneficiary_details_incomplete on every currency. A GBP OpenPayd payee also needs country (defaulted for you), and a CHAPS one needs city and postal_code. For GBP Faster Payments or CHAPS, send either sort_code plus account_number, or a valid UK iban. A supplied UK IBAN is checked for national format and checksum; its six-digit sort code and eight-digit account number are extracted without losing leading zeros. Spaces and lowercase letters in the IBAN are normalized. If you also send local account/sort details, the effective provider attributes must agree with the IBAN; mismatches, explicit blank provider fields and non-GB IBANs return 422 beneficiary_details_incomplete before creating or replacing a payee. This does not select SWIFT automatically. The beneficiary's address country may differ from the bank's country; always send the real address country.
  7. IBAN country must agree with the bank country (USD only, pre-checked). An IBAN's first two letters are the account's country; bank_country / bank_address_country must match it or a territory of it (GB accepts JE, GG, IM; FR its overseas territories; FI accepts AX; NO accepts SJ). If the mismatching bank is a correspondent, put it in the intermediary fields.
  8. attributes shadows the typed fields. Every key you put in attributes is applied first and is never overwritten, so attributes.name, attributes["address.country"], attributes["bankDetails.iban"] and the like replace the typed value — and shadowed values bypass the USD pre-check. Use it only for provider keys that have no typed field (footnote 6).

Two whole-payee USD rules that are not one field: iban and account_number are mutually exclusive (refused naming bankDetails), and state is required when the beneficiary's country is US or CA.

Reading the response

POST and PATCH return data.beneficiaries — an array, one row per provider the customer holds an eligible account with in that currency. Normally that is exactly one row, but do not hard-code beneficiaries[0] as the only result; store every id.

HTTP 200 does not mean the payee was created. Read each row:

status Meaning What to do
Completed with provider_reference set Local beneficiary setup completed; OpenPayd bank registration is separate. Check approval_status and bank_registration next.
PendingProvider Accepted, not yet confirmed by the provider. Poll GET .../beneficiaries/{beneficiaryId}.
ManualReview The provider call failed or was skipped; an operator must act. The reason is not returned. Check the table above, fix the body, create a new payee under a new Idempotency-Key — replaying the same key returns the parked row.
Failed with provider_status LOCAL_DISABLED You deleted or superseded it. Nothing; it stays in the list.
Pending, Returned, Closed Part of the shared vocabulary; not expected on a beneficiary. Treat as not payable.

Three things put a row at ManualReview behind a 200: the provider refused the payee (any missing field in the P column or a bad type); the customer has no active end-user record at Fiat Republic yet; or the routed provider has no beneficiary capability. (An OpenPayd payee without the name attributes used to be a fourth cause — since 2026-09-02 that is refused at intake with 422 beneficiary_details_incomplete instead.) provider_status is the provider's own word plus the Kwiikpay literal LOCAL_DISABLED — informational only, never branch on it.

supported_schemes reports the saved OpenPayd rail once registration has been requested, including on get and list responses. Use that rail for withdrawals: SEPA and SEPA_INSTANT are different registrations; FASTER_PAYMENTS is accepted as FPS by withdrawal routes. For Fiat Republic USD payees it remains the provider's pre-flight result on create/update. Null means unknown, never "no schemes".

The approval gate

Every new beneficiary — and every replacement made by PATCH — starts with approval_status PendingApproval and is cleared by Kwiikpay staff. Until it is Approved, a withdrawal is refused with 409 beneficiary_not_approved; Rejected is final. A payee can be Completed with a provider reference and still be unpayable for this reason.

approval_status is populated on every response — the create, update and delete writes as well as GET .../beneficiaries/{beneficiaryId} and the list. Poll the GET route to watch a pending decision move. Rows created before the gate existed report Approved.

A beneficiary.approved / beneficiary.rejected event is emitted when staff decide, and both are subscribable on the webhook surface (POST /api/v2/public/webhook-subscriptions). They arrive in the kwiikpay.webhooks.v1 payload shape, not the legacy envelope — see Webhooks. Polling the GET route works too.

The complete payability test is: status == "Completed" and provider_reference != null and approval_status == "Approved". An OpenPayd row with requested bank registration also requires bank_registration.status == "Registered".

OpenPayd bank registration

Staff approval queues registration in OpenPayd under the linked business belonging to the customer in the request path. For a parent acting for a sub-customer, this is the child's linked business. The beneficiary identity is the recipient's company name or personal names; it is never replaced with the parent's or owning customer's name.

The API exposes bank_registration on create/replay, get, list, update and delete responses:

bank_registration.status Meaning / action
NotRegistered Historical/local entry that has not been queued. Existing approved historical payees keep their inline payout flow until selected for backfill.
Queued, Processing Registration is pending. Poll GET before attempting a withdrawal.
Reconciling The bank's result is being checked. Keep polling; do not create duplicate payees or retry with a new idempotency key.
Registered Bank ownership, recipient, account, currency and rail have been verified. Use the usual approval/readiness checks too.
NeedsReview Support must reconcile missing or conflicting details. Creating another row does not repair it.

The beneficiary.approved webhook confirms staff approval, not bank registration. Poll the beneficiary GET route for bank confirmation; there is no separate registration webhook. provider_reference remains the original local reference for OpenPayd compatibility; it is not the saved OpenPayd bank-beneficiary ID. Always submit Kwiikpay's beneficiary id. Bank-holder IDs and staff review details are not exposed in customer responses.

Staff can preview and queue eligible historical entries using the dashboard's Register with OpenPayd action. Existing exact bank records are reused after verification. Ambiguous names, ownership or bank records are held for review; an uncertain create is not repeated.

Update is a replace

Once an OpenPayd row has requested bank registration, PATCH returns 409 beneficiary_registration_immutable, preserving the existing beneficiary. Contact support to change its registered details. A concurrent registration during replacement returns beneficiary_registration_changed; refresh the list and review any pending replacement.

For other entries, PATCH creates a replacement from the body you send and supersedes the old row. Nothing is merged from the existing payee except currency and provider when you omit them — send the complete payee exactly as on create. Consequences:

Delete is local

DELETE sets status to Failed and provider_status to LOCAL_DISABLED, returns data.beneficiary (singular), and is a no-op on a row already Failed. The payee is not removed at the banking provider, and the row keeps appearing on the list route — filter deleted payees out client-side with that Failed / LOCAL_DISABLED pair.

Listing

GET .../beneficiaries takes page and limit (1-500) and returns real totals in pagination. Every status is included. approval_status and OpenPayd bank_registration are populated; supported_schemes carries the saved rail for requested OpenPayd registrations.

Errors, per route

Refusals come in three body shapes — see Errors. Below, envelope means { success, status_code, message, data: { code, detail } }, flat means { title, detail, status, code }, and problem means RFC 7807 application/problem+json.

POST .../beneficiaries

code Status When Fix
idempotency_key_invalid (problem) 400 Idempotency-Key missing, duplicated or malformed. Send exactly one header of 8-256 visible ASCII characters.
— (flat) 404 The customer does not exist or your key cannot act for it. Check customerId.
kyc_or_kyb_not_approved 409 The customer is not verified. Complete onboarding first.
unsupported_currency 422 Currency outside GBP, EUR, USD. Use a platform currency.
unsupported_payment_scheme 422 A schemes entry is not ACH, ACH_SAME_DAY, DOMESTIC_WIRE or INTERNATIONAL_WIRE (USD). Fix the scheme list.
beneficiary_details_incomplete 422 The USD per-scheme check failed, OpenPayd identity is incomplete/invalid, or a supplied domestic GBP IBAN/local-coordinate combination is invalid or conflicting; data.detail names the fields. Correct the named fields; see footnote 6 for OpenPayd identity and GBP bank details.
unsupported_provider 422 provider is not FIAT_REPUBLIC or OPENPAYD. Omit provider, or name one of the two.
bank_account_required 409 No account in this currency has a provider reference (at the named provider, if you named one). Open the account first; wait for provider_account_id.
beneficiary_provider_validation_failed 422 USD at Fiat Republic: the provider's own pre-flight refused; data.detail lists the fields per scheme. Fix the named fields.
beneficiary_creation_failed 409 The workflow threw for a provider; data.detail is prefixed with the provider code. Not atomic: an earlier provider's payee may already exist. List before retrying.

PATCH .../beneficiaries/{beneficiaryId} — the same rows, with beneficiary_update_failed in place of beneficiary_creation_failed, and 404 (flat) when the beneficiary does not belong to this customer.

DELETE .../beneficiaries/{beneficiaryId} — 400 (problem) for the Idempotency-Key, 404 (flat) for an unknown customer or beneficiary, 409 kyc_or_kyb_not_approved.

GET .../beneficiaries/{beneficiaryId} and GET .../beneficiaries — 404 (flat) only; a beneficiary belonging to another customer is indistinguishable from one that does not exist.

Idempotency replays on all three mutating routes can also answer 409 idempotency_key_reused or idempotency_key_in_progress as problem bodies — see Errors.

Then: withdrawing

Withdrawing for a sub-customer

See Sub-customers & parent access for the complete request sequence, including which customer ID to use, beneficiary creation and payment retries.

A parent can use its subject-bound API key with POST /api/v1/partner/customers/{subCustomerId}/banking/withdrawals. The sub-customer must belong directly to that parent in the same tenant. The key needs WithdrawalsWrite; use WithdrawalsRead for the corresponding list route. An unrelated or unknown sub-customer returns 404.

Create and select beneficiaries using the sub-customer's ID in the beneficiary routes above. A parent's or sibling's beneficiary cannot be used. The withdrawal debits the sub-customer's available balance, including its effective fee; the parent's own balance is not a fallback. Both accounts must be active and pass the applicable compliance controls. Parent controls can return flat errors parent_not_active (409), compliance_subject_blocked (403), compliance_subject_manual_review (409), or parent_compliance_unavailable (503). Existing source-account and client-money restrictions still apply.

Send an Idempotency-Key for each intended payment. If the response is lost or times out, retry with the same key and unchanged body; do not create a replacement payment while the outcome is unknown. Read the returned withdrawal status and track it through the sub-customer's withdrawal list.

In the dashboard, open Sub-customers → the sub-customer → Fiat withdrawals → Withdraw fiat. Add an approved beneficiary, review the amount and fee, then submit. New beneficiaries need staff approval before they can receive a withdrawal. Recent withdrawals on that page show only that sub-customer's payments.

Withdrawal readiness and outcome

POST .../banking/withdrawals with beneficiary_id, currency and amount (minor units). The withdrawal refuses, in order: 422 beneficiary_not_found (not this customer's), 422 beneficiary_currency_mismatch, 409 beneficiary_not_ready (not Completed with a provider reference), 409 beneficiary_not_approved, then 409 source_account_not_ready when the customer has no Completed account at the beneficiary's provider in that currency. Those are not the last gates: the route can still refuse with 409 (withdrawal_client_money_blocked, source_account_pot_missing, fiat_withdrawal_prerequisites_missing) or 422 (unsupported_rail, rail_not_supported_by_beneficiary, invalid_payment_reference) — the full list is in Errors. Only once the payment is accepted into the withdrawal workflow does the route return 200 for every remaining outcome, including a provider refusal after the hold is taken (the hold is released and the row reads Failed) — so from that point on, read data.withdrawal.status, never the HTTP code. Rails, defaults and per-rail limits are in Currencies, rails and providers.