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:
POST .../banking/accountsin the currency, then pollGET .../banking/accounts/{accountId}untilprovider_account_idis set (statusCompletedis what you want before withdrawing).POST .../beneficiariesin that currency.GET .../beneficiaries/{beneficiaryId}untilstatusisCompletedandapproval_statusisApproved. For OpenPayd registrations, also wait forbank_registration.statusto becomeRegistered.POST .../banking/withdrawalswith the beneficiary'sid.
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:
- 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
statusManualReviewafter a provider refusal. Fiat Republic requires an exact uppercasePERSON/BUSINESStype; an invalid type can also park there. - The generic
namedefaults to the customer's own display name when omitted. This default never supplies OpenPayd's beneficiary identity. Always send the payee's own name. countryis defaulted on GBP and EUR — to the customer's own country, and if that is blank, toGB. A UK customer's German payee is registered with a GB address unless you send it. On USD it is required and never guessed.- At least one bank coordinate. Fiat Republic refuses a payee with none of
account_number,iban,bicor a routing code; on GBP/EUR that refusal is a 200 +ManualReview. A GBP payee is sent assort_code+account_number; a EUR payee asiban(+bic). - Routing-code precedence. Routing codes are collected in the order
sort_code,aba_routing_number,wire_routing_number, typed (sort_codebecomes ABA on USD, or WIRE when the only scheme is DOMESTIC_WIRE) and de-duplicated by type, first wins — asort_codeon a USD body silently discards an explicitaba_routing_number. At most two routing codes per payee. Use the explicit fields on USD; never sendsort_codewith them. - OpenPayd needs a company name or separate personal names. For a company, send
type: "BUSINESS"and a nonblankname; these supplybeneficiary_company_nameon both create and replacement. OpenPayd also acceptsCOMPANY/CORPORATEhere, ignoring case and surrounding spaces. Both type and name must be explicit: customer defaults are never used for this mapping. Explicitattributes.type/attributes.nameretain precedence. Alternatively sendattributes.beneficiary_company_namedirectly. For a person, sendattributes.beneficiary_first_nameandattributes.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 beRETAILorCORPORATE; an explicitRETAILis never converted to a company. Missing or invalid identity is refused at intake with 422beneficiary_details_incompleteon every currency. A GBP OpenPayd payee also needscountry(defaulted for you), and a CHAPS one needscityandpostal_code. For GBP Faster Payments or CHAPS, send eithersort_codeplusaccount_number, or a valid UKiban. 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 422beneficiary_details_incompletebefore 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. - 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_countrymust 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. attributesshadows the typed fields. Every key you put inattributesis applied first and is never overwritten, soattributes.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:
- The response carries new ids; the id in the URL is not the id you get back.
- The old row is disabled only once the replacement is usable (
CompletedorPendingProviderwith a provider reference). If the replacement parks atManualReview, the old payee stays active and you hold two ids. - The replacement is
PendingApprovalagain.
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.