diff --git a/api-reference/third-party-payouts.md b/api-reference/third-party-payouts.md index 74e5da2..3b7297e 100644 --- a/api-reference/third-party-payouts.md +++ b/api-reference/third-party-payouts.md @@ -223,6 +223,17 @@ that is easiest. Paying a Chinese company from your company (B2C) is not available on this rail yet. +### Dollars to China, Hong Kong and Singapore: not yet + +The catalog can list three USD bank rails — `usd-wire-china`, `usd-wire-hong-kong` and +`usd-wire-singapore` — and a recipient may hold a destination on one of them. Paying +it is not available on this API yet: a quote or an initiate for a USD payout on any of +the three answers `400 INVALID_REQUEST` with `provider_confirmation_pending` in the +message, before any money moves. + +Read that as "not offered", not as a fault to retry. When USD payouts open to partners, +it will be announced here and in the changelog. + ### Paying a company: `beneficiary_type` A format may have a business sibling (`cnaps-bank` ↔ `cnaps-bank_business`) that diff --git a/changelog.md b/changelog.md index c2b615a..fd42e97 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,32 @@ Notable changes to the Unigox partner API, newest first. +## 2026-09-21 + +**USD payouts to China, Hong Kong and Singapore are not on the partner API yet.** The three USD bank rails can appear in `/api/v1/supported/payment-rails`, and a recipient can hold a destination on them, but a quote or an initiate for a USD payout on any of them answers `400 INVALID_REQUEST` with `provider_confirmation_pending`. This is a statement of what is offered, not a change: nothing that worked before stops working. See [Dollars to China, Hong Kong and Singapore](./api-reference/third-party-payouts.md#dollars-to-china-hong-kong-and-singapore-not-yet). + +## 2026-09-18 + +**Fiat accounts are rebuilt around one id, and the identity endpoints are gone.** An account a customer is paid into is now a resource of its own: `POST /api/v1/partner/fiat-accounts` with `{user_uuid, currency}` opens it, and everything about it is addressed by `fiat_account_id`. This replaces the whole previous surface. There is no compatibility window — the old routes are removed. + +- **One id, called `fiat_account_id`.** A uuid we mint, the way `user_uuid` names a person. There is no field called `id` on an account, no `retail_` prefix, and nothing that names the bank behind it. It is what the path takes, what a webhook names the account by, and what an order funded from the account carries. +- **`GET /users/{user_uuid}/identity` and `/identification` are removed.** They were the banking vendor's own KYC push in partner clothing, and they asked you to maintain a second identity for a person you had already verified with us. Everything the bank needs now comes from the KYC record. When it needs a field that record does not have, issuance answers `422 ISSUANCE_NOT_READY` and names the fields the way `PATCH /api/v1/partner/users/{user_uuid}/kyc` takes them — `address`, `city`, `postal_code`, `dob`, `id_number`, `id_type`. `GET /api/v1/partner/users/{user_uuid}` carries the same answer up front, under `fiat_account_issuance`. +- **Accounts are no longer nested under the customer.** `GET /fiat-accounts?user_uuid=`, `GET /fiat-accounts/{fiat_account_id}` and `…/transactions` replace the `/users/{user_uuid}/fiat-accounts` tree. An account id is enough to read an account; one that is not yours answers `404`. +- **An account has a status you can act on:** `pending`, `active`, `failed`, `closed`. A `pending` account carries no pay-in details, because there is nowhere to pay in yet. `fiat_account.updated` fires when that changes. +- **A deposit has its own webhook at last.** `fiat_account.deposit.received` fires on every credit that lands on an issued account, carrying `transaction_id`, the amount, and `order_id` when the deposit funded an on-ramp. Previously nothing told you money had arrived unless collection into your own account was switched on, which for most partners it is not. +- **`retail.settlement.completed` is no longer published.** Collecting a customer's deposit into your operating account is an internal arrangement most partners do not have on, and it was never the event you needed: the deposit is. It will come back, named from your side, if and when collection ships as a product. +- **One transactions list.** `GET /fiat-accounts/{fiat_account_id}/transactions` replaces the separate ledger and payments reads. v1 is receive-only, so every row is a `credit`, and a credit that funded an order names it. +- **On-ramp orders paid from the customer's own account say `fiat_funding_source: "own_account"`** (it was `own_iban`, which was wrong the moment the product covered sterling) and carry the `fiat_account_id` they are funded from. `next_action` is still `deposit_to_user_account` and `confirm-payment-sent` is still refused with `409 OPERATION_NOT_ALLOWED`. +- Gone with the removed routes: `CLIENT_NOT_APPROVED`, `ACCOUNT_HOLDER_NOT_FOUND`, `IDENTIFICATION_MISSING`, `IDENTIFICATION_ALREADY_LINKED`, `HOLDER_REGISTRATION_IN_PROGRESS` and `HOLDER_UNAVAILABLE`. New: `KYC_NOT_CLEARED` and `ISSUANCE_NOT_READY`. + +## 2026-09-15 + +**Interac e-Transfer no longer asks for an `institution_id` the catalog said it did not need.** `/api/v1/supported/payment-rails` reports `institution_required: false` for `interac-e-transfer` — an e-Transfer is addressed to the recipient's email or phone number, not to a bank you pick — but creating a destination on it without an `institution_id` failed with `institution_id is required for this rail`. A partner following the catalog was refused, which was our bug, the same one fixed for the Chinese wallet rails on 2026-08-28. + +- Omitting `institution_id` on `interac-e-transfer` now works. The destination resolves to `interac-e-transfers` and goes through the same checks as if you had named it. +- Sending an `institution_id` is still accepted: `interac-e-transfers`, or any bank `/api/v1/supported/institutions?rail=interac-e-transfer` lists. +- Destinations you already created are unchanged. + ## 2026-09-14 **An on-ramp order can now be paid from the fiat account you issued the customer.** Instead of wiring the order's fiat to a vendor and calling `confirm-payment-sent`, the customer transfers the amount into their own account and the order moves on by itself. diff --git a/fiat-accounts.md b/fiat-accounts.md index e564321..edeb6ee 100644 --- a/fiat-accounts.md +++ b/fiat-accounts.md @@ -1,105 +1,87 @@ # Fiat accounts -Give your customers a bank account of their own — a dedicated IBAN they can be +The how-to. The endpoint-by-endpoint reference is the **Fiat Accounts** section +of the API reference. + +Give your customers an account of their own, in their own name, for them to be paid into — and read what lands on it. **What you will build.** By the end of this guide one of your customers holds a -real EUR or GBP account in their own name, you can show them where to send -money, and you can read the balance, the transaction history and every incoming -payment. - -**What you need first.** A partner API key, and the `retail` product activated on -your partner — plus the `issue_retail_accounts` capability if you want to open -accounts rather than only verify people for them. Neither is self-service; ask -Unigox. Until they are on, the write endpoints answer `403` and -`GET /fiat-accounts/config` reports `enabled: false`, which is the check to run -first. - -**How long it takes.** Three calls per customer once they are KYC-verified, and -approval is usually immediate. It is not guaranteed to be, which is why step 4 -is a poll rather than a wait. - -## The one idea to hold on to - -An account belongs to a **person**, and that person is a customer you already -have — the one you created with `POST /api/v1/partner/users` and put through -KYC. It is not a separate banking record with its own identity to register and -keep in step. - -Everything follows from that. The routes live under -`/api/v1/partner/users/{user_uuid}/…`, beside the KYC and payment-details routes -for the same customer. An account id on its own is not enough to read an -account — you address it through its holder. And the identity the bank opens the -account on is the one Unigox verified, not one you retype into a request body. - -Accounts here are held by individuals. An account issued to a company you -onboarded through business (KYB) onboarding is not on this API: its holder is a -KYB case rather than a customer, so it cannot be addressed through this tree. -Those remain available in the Unigox console. +real EUR or GBP account, you can show them where to send money, and you can see +every deposit that arrives, including the one that funds an on-ramp order. + +**What you need first.** A partner API key and the fiat accounts product +switched on for you. It is not self-service; ask Unigox. +`GET /api/v1/partner/fiat-accounts/config` answers whether it is on, and is the +check to run first. + +**How long it takes.** One call per customer once they are KYC-verified. The +bank usually opens the account immediately, but not always, which is why the +account has a `status` and fires a webhook when it changes. + +## An account belongs to a customer, and has one id + +The holder is a customer you already have: the one you created with +`POST /api/v1/partner/users` and put through KYC. There is no second identity to +register. + +The account is addressed by **`fiat_account_id`**, a uuid, the way a person is +addressed by `user_uuid`. That is the only id on it: there is no field called +`id`, and nothing names the bank behind the account. + +``` +Your partner account + └── customer (user_uuid) + ├── KYC — who they are + ├── payment details — THEIR outside bank, where off-ramp money goes + ├── fiat accounts — OUR issued account, where money comes in + └── orders — conversion; an on-ramp may be funded from the account +``` + +Accounts are held by individuals. An account issued to a company you onboarded +through KYB onboarding is not on this API. + +## What you can and cannot do + +You can open accounts and read them. v1 is **receive-only**: moving money off an +account is not on this API, and converting what arrives is an order, not a +transfer. ## Conventions -Nothing here departs from the rest of the Partner API. Responses are wrapped: +The same as the rest of the Partner API. Responses are wrapped: ```json -{ "success": true, "data": { "config": { … } } } +{ "success": true, "data": { … } } ``` Errors carry a machine `code` you can branch on, a human `message`, and — for a -validation failure — the request fields at fault: +gap in the customer's record — the fields at fault: ```json { "success": false, "error": { - "code": "MISSING_FIELDS", - "message": "The banking layer needs a few details this customer's verification did not capture.", - "details": { "missing_fields": ["address", "birthdate"] } + "code": "ISSUANCE_NOT_READY", + "message": "The bank needs a few details this customer's KYC record does not have.", + "details": { "missing_fields": ["address", "postal_code"] } } } ``` -This is the same `{success, error:{code, message, details}}` shape the order and -off-ramp endpoints use — `code` is stable and safe to branch on, `message` is for -your logs and your support team, and `details` carries whatever the particular -refusal can say. - -Field names are `snake_case` at every depth. Authentication is the same -`X-API-Key` as everywhere else, and every id is scoped to you: a customer or an -account you do not own answers `404`, indistinguishable from one that does not -exist. +Field names are `snake_case` at every depth, authentication is the same +`X-API-Key`, and every id is scoped to you: an account or a customer you do not +own answers `404`, indistinguishable from one that does not exist. -**Account ids are opaque** — `retail_412`. Pass them back verbatim; do not parse -them and do not assume the numeric part means anything. The prefix is part of -the id, not decoration. - -An account is always addressed under its own customer. One that belongs to a -different customer answers `404`, exactly as one that does not exist — so an -account id alone is never enough to read an account. - -Nothing in this section identifies the bank or banking platform behind an -account, and nothing branches on it. Which institution issues a given currency -is an operational detail Unigox may change; your integration should not be able -to tell. `bank_name` and `bic` describe the account a payer will send money to, -which is different — those are yours to display. - -## What you can and cannot do - -You can **issue** accounts and **read** them. You cannot move money on this API: -funding, conversions, closing an account and outbound payments are -Unigox-operated and are not exposed here. Nothing in this section debits an -account. - -## End-to-end flow +## End to end 1. Create and KYC-verify a customer (`POST /api/v1/partner/users`, then the KYC - flow). Reuse an existing verified customer if you have one. + flow). Reuse a verified customer if you have one. 2. Check what you can offer: `GET /fiat-accounts/config`. -3. Check what identity is still needed: `GET /users/{user_uuid}/identity`. -4. Submit the customer's identity: `POST /users/{user_uuid}/identification`, then - poll `GET /users/{user_uuid}/identification` until it approves. -5. Issue the account: `POST /users/{user_uuid}/fiat-accounts`. -6. Read balances, ledger and payments as deposits arrive. +3. Check the customer is ready: `GET /users/{user_uuid}`. +4. Open the account: `POST /fiat-accounts`. +5. Wait for `active`, then show the customer where to pay in. +6. Read what arrives: `GET /fiat-accounts/{fiat_account_id}/transactions`. ### 1. See what you can offer @@ -112,213 +94,221 @@ X-API-Key: { "success": true, "data": { - "config": { - "enabled": true, - "issues_accounts": true, - "currencies": ["EUR", "GBP"], - "issuers": { "EUR": ["NL", "MT"], "GBP": ["GB"] }, - "postal_code_issuers": ["NL"] - } + "enabled": true, + "issues_accounts": true, + "currencies": ["EUR", "GBP"], + "issuers": { "EUR": ["NL", "MT"], "GBP": ["GB"] } } } ``` -`enabled` and `issues_accounts` tell you whether you may submit identities and -open accounts. `currencies` and `issuers` (currency → jurisdictions, default -first) tell you what an account may be denominated in, and where it can be -issued. `postal_code_issuers` names the jurisdictions that will not issue -without a postal code on file — see step 5. +`enabled` and `issues_accounts` say whether you may use the product and whether +you may open accounts with it. `currencies` and `issuers` (currency → +jurisdictions, default first) say what an account may be denominated in, and +where it can be issued. -This endpoint never errors on entitlement. When the product is off it returns -`enabled: false` with a `disabled_reason` slug. +Entitlement is never an error here: when the product is off this still answers +`200`, with `enabled: false` and a `disabled_reason`. -### 2. See what identity is still needed +### 2. Check the customer is ready ```http -GET /api/v1/partner/users/{user_uuid}/identity +GET /api/v1/partner/users/{user_uuid} X-API-Key: ``` -Returns what our KYC already established about this customer, and `missing` — -the fields the banking layer still needs. `ready: true` means step 3 will be -accepted with an empty body. +The customer carries one more object: + +```json +"fiat_account_issuance": { + "ready": false, + "missing_fields": ["address", "postal_code"] +} +``` + +`ready: true` means the next step will be accepted. Anything under +`missing_fields` is a field the bank needs and the KYC record does not have, +named the way `PATCH /api/v1/partner/users/{user_uuid}/kyc` takes it: +`address`, `city`, `postal_code`, `dob`, `id_number`, `id_type`. Fill them there +and the customer is ready. -This is a read: it creates nothing and calls no one, so it is safe to poll while -you decide whether to offer the product to a given customer. The document -number is never returned — only its last four digits. +You can skip this check and read the same list off the refusal in step 3. -### 3. Submit the customer's identity +### 3. Open the account ```http -POST /api/v1/partner/users/{user_uuid}/identification +POST /api/v1/partner/fiat-accounts X-API-Key: Content-Type: application/json ``` ```json -{ "address": "10 Downing Street", "city": "London", "birthdate": "1990-04-17" } +{ + "user_uuid": "550e8400-e29b-41d4-a716-446655440000", + "currency": "EUR", + "issuer_country": "NL" +} ``` -**Every field is optional.** What Unigox verified at KYC is always preferred -over what you send, so send only what step 2 reported as `missing`. The name, -the country of residence and the document are taken from the verification — -sending your own copy cannot override them, by design: the identity a bank opens -an account on has to be the one that was verified. - -Accepted fields: `address`, `city`, `birthdate` (`YYYY-MM-DD`), -`document_type`, `document_number`, `email`, `country_of_residence`. Document -types are `PASSPORT`, `NATIONAL_ID`, `DRIVERS_LICENCE`, `WORK_PERMIT`. - -The customer must be KYC-verified by Unigox before this is accepted. The -document number is used for the submission and is not stored; only its last four -digits are kept. - -A gap answers `400` with `error.code: "MISSING_FIELDS"` and the field list under -`error.details.missing_fields`. - -### 4. Wait for approval - -```http -GET /api/v1/partner/users/{user_uuid}/identification -X-API-Key: -``` +`currency` is required and must be one `config.currencies` offers. +`issuer_country` is optional: omitted, the currency's default jurisdiction is +used. `postal_code` is accepted for the one jurisdiction that will not issue +without one, and only when the KYC record has none; sent here, it is written +onto that record. ```json { "success": true, "data": { - "holder": { - "user_uuid": "9f1c…", - "full_name": "Maria ZALISHCHUK", - "status": "approved", - "kyc_status": "approved", - "can_open_accounts": true - } + "fiat_account_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "user_uuid": "550e8400-e29b-41d4-a716-446655440000", + "currency": "EUR", + "issuer_country": "NL", + "status": "active", + "created": true, + "iban": "NL91ABNA0417164300", + "bic": "ABNANL2A", + "bank_name": "ABN AMRO", + "holder_name": "Maria ZALISHCHUK", + "created_at": "2026-09-18T10:00:00Z" } } ``` -Poll this until `can_open_accounts` is `true`. `status` moves -`draft → pending_review → approved | rejected`. Approval is usually immediate -but is not guaranteed to be, which is why this is a poll rather than a -synchronous answer on step 3. +**Idempotent per (customer, currency, jurisdiction).** A repeat answers `201` +with the same account and no `created` flag; it does not open a second one. One +customer may hold accounts in several currencies, and in several jurisdictions +of the same currency. -It is a `GET`: checking a verification does not change anything, so it is safe -to retry and safe to run on a schedule. A customer you have not submitted yet -answers `404 ACCOUNT_HOLDER_NOT_FOUND` — polling never creates a holder record. +Registering the person with the bank happens behind this call, from the identity +Unigox verified. There is nothing to submit and no second record to keep in +step. -### 5. Issue the account +- The customer is not verified: `422 KYC_NOT_CLEARED`. +- The bank needs a field the KYC record does not have: `422 + ISSUANCE_NOT_READY`, with `error.details.missing_fields`. Patch the KYC record + and post again. +- The same account is already being opened: `409 PROVISIONING_IN_PROGRESS`. + +### 4. Wait for `active` + +An account is `pending` until the bank has opened it, and it carries no pay-in +details while it is: an account that is not open has nowhere to receive money, +and a customer sent to it loses the transfer to a bounce. + +| `status` | What it means | +| --- | --- | +| `pending` | Being opened. Do not tell the customer to transfer yet. | +| `active` | Open. The pay-in details are on the account and money may be sent. | +| `failed` | The bank refused. This account will not become usable. | +| `closed` | Retired. Its history stays readable; new deposits will not credit it. | + +`fiat_account.updated` fires when this changes, and +`GET /fiat-accounts/{fiat_account_id}` answers the same thing when you poll. + +### 5. Show the customer where to pay in ```http -POST /api/v1/partner/users/{user_uuid}/fiat-accounts +GET /api/v1/partner/fiat-accounts/{fiat_account_id} X-API-Key: -Content-Type: application/json ``` -```json -{ "currency": "EUR", "issuer_country": "NL", "postal_code": "1011 AB" } -``` +The pay-in details are shaped by the rail the account is on: + +| Currency | Fields | +| --- | --- | +| EUR (SEPA) | `iban`, `bic`, `bank_name`, `holder_name` | +| GBP (Faster Payments) | `account_number`, `sort_code`, `bank_name`, `holder_name` | + +Keys that do not apply are absent rather than empty. This view also carries +`balances`, and `balances_unavailable: true` when the balance read failed: a +balance we could not read is reported as unavailable rather than as zero. + +`GET /api/v1/partner/fiat-accounts?user_uuid={user_uuid}` lists one customer's +accounts, with the last four digits of the identifier rather than the whole one. -`currency` is required and must be one `GET /fiat-accounts/config` offers. -`issuer_country` is optional — omitted, the currency's default jurisdiction is -used; supplied, it must be one of that currency's `issuers`. `postal_code` is -required only when the chosen jurisdiction is in `postal_code_issuers`, and is -otherwise ignored. +### 6. Read what arrives + +```http +GET /api/v1/partner/fiat-accounts/{fiat_account_id}/transactions?page=1 +X-API-Key: +``` ```json { "success": true, "data": { - "account": { - "id": "retail_412", - "holder_type": "retail", - "holder_id": "9f1c…", - "currency": "EUR", - "issuer_country": "NL", - "status": "active", - "iban_last4": "8827", - "bank_name": "ABN AMRO", - "bic": "ABNANL2A", - "holder_name": "Maria ZALISHCHUK", - "created_at": "2026-09-09T11:04:22Z" - }, - "created": true + "transactions": [ + { + "transaction_id": "5f1b2c9a-1d44-4f0e-9c1a-2b5f0d7e9a31", + "type": "credit", + "amount": "500.00", + "currency": "EUR", + "order_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", + "description": "Invoice 2026-114", + "created_at": "2026-09-18T10:04:22Z" + } + ], + "has_more": false } } ``` -**This endpoint is idempotent per (customer, currency, jurisdiction).** A repeat -answers `201` with the same account and `created: false`; it does not open a -second one. One customer may hold accounts in several currencies, and in several -jurisdictions of the same currency — `EUR/NL` and `EUR/MT` are two accounts, and -each is opened by naming its `issuer_country`. +v1 is receive-only, so every row is a `credit`. `order_id` names the on-ramp +this deposit funded, and is `null` when it funded none: that money simply stays +on the customer's account. -Two requests racing for the same account answer `409 -PROVISIONING_IN_PROGRESS` — one of them is already opening it. Retry once it -settles rather than treating it as a failure. +## Webhooks -### 6. Read the account +Two events, in the same envelope and with the same signature as +`order.status.changed`. -```http -GET /api/v1/partner/users/{user_uuid}/fiat-accounts # this customer's accounts -GET /api/v1/partner/users/{user_uuid}/fiat-accounts/{id} # one account, full details -GET /api/v1/partner/users/{user_uuid}/fiat-accounts/{id}/ledger?page=N # transaction history -GET /api/v1/partner/users/{user_uuid}/fiat-accounts/{id}/payments?page=N # incoming payments -``` +| `event_type` | Fired when | `data` | +| --- | --- | --- | +| `fiat_account.updated` | The account's status changed. No money moved. | `fiat_account_id`, `user_uuid`, `status`, `currency` | +| `fiat_account.deposit.received` | Money arrived on the account. | `fiat_account_id`, `user_uuid`, `transaction_id`, `amount`, `currency`, `order_id` (nullable) | + +Register your endpoint with `POST /api/v1/partner/webhooks` as usual; there is +no per-event subscription. -The list carries `iban_last4` (or `account_number_last4` and `sort_code` for a -sterling account, which has no IBAN). The single-account view adds the full -identifier a payer needs, plus `balances` and `balances_unavailable` — a balance -read that failed is reported rather than shown as zero. +## Paying for an on-ramp from the account -**A closed account stays readable.** Closing retires the IBAN; it does not -remove the account, its history, or any money still behind it. Closed accounts -keep appearing in the list with `status: "closed"` and answer every read. +When a customer holds an account in the order's currency, the on-ramp is funded +from it instead of from a vendor's bank details. Such an order carries +`fiat_funding_source: "own_account"` and the `fiat_account_id` it is funded +from, its `next_action` is `deposit_to_user_account`, and it has no +`vendor_payment_details`. -Ledger and payment pages carry the upstream `pagination` object when one is -available. Its **absence means unknown, not "one page"** — fall back to judging -by the length of the page you got. +The customer transfers the order's amount into their own account, and the +deposit completes the order: `confirm-payment-sent` is not used and answers +`409 OPERATION_NOT_ALLOWED`. The transaction on the account carries that +`order_id`, so the two books can be reconciled against each other. -## What happens when a deposit lands +Open the order first, then have the customer transfer. A deposit is matched to +an open order of the same customer by amount, to the cent; two open orders for +the same amount are held for review rather than guessed between. -By default a deposit stays on the customer's account. Unigox can enable -partner-level collection into your master account; when a collection settles, -the `retail.settlement.completed` webhook fires with the settlement id, the -amount, the currency and the account it came from. Register webhooks with -`POST /api/v1/partner/webhooks` as usual. +Off-ramp does not pay out of this account in v1. The customer's outside bank +stays `payment_details`, and a third party stays a Recipient. ## Errors | `error.code` | Status | What it means | | --- | --- | --- | -| `MISSING_FIELDS` | 400 | See `error.details.missing_fields`. | -| `INVALID_DOCUMENT_TYPE` | 400 | Not one of the four accepted document types. | -| `INVALID_POSTAL_CODE` | 400 | Longer than the banking layer accepts. | | `UNSUPPORTED_CURRENCY` | 400 | Not in `config.currencies`. | | `UNSUPPORTED_ISSUER_COUNTRY` | 400 | Not in `config.issuers[currency]`. | -| `POSTAL_CODE_REQUIRED` | 400 | This jurisdiction will not issue without one. | +| `POSTAL_CODE_REQUIRED` | 400 | This jurisdiction will not issue without one, and neither the body nor the KYC record has it. | | `PRODUCT_NOT_ACTIVATED` | 403 | The product is not active on your partner. | -| `ISSUANCE_NOT_GRANTED` | 403 | You may verify customers but not open accounts for them. | -| `ISSUANCE_DISABLED` | 403 | Account issuance is switched off platform-wide. | -| `CURRENCY_NOT_PERMITTED` | 403 | This holder's issuance is limited to other currencies. | +| `ISSUANCE_NOT_GRANTED` | 403 | You may read accounts but not open them. | | `CUSTOMER_NOT_FOUND` | 404 | No such customer, or not yours. | -| `FIAT_ACCOUNT_NOT_FOUND` | 404 | No such account, not yours, or not this customer's. | -| `ACCOUNT_HOLDER_NOT_FOUND` | 404 | No such account holder under that customer. | -| `PROVISIONING_IN_PROGRESS` | 409 | The same account is already being opened. | -| `HOLDER_REGISTRATION_IN_PROGRESS` | 409 | This customer is already being registered as a holder. Retry once it settles. | -| `IDENTIFICATION_ALREADY_LINKED` | 409 | This person is already an account holder under a different record. | -| `CLIENT_NOT_APPROVED` | 422 | The identity is not approved yet. | -| `IDENTIFICATION_MISSING` | 422 | Submit the identity before opening an account. | -| `CUSTOMER_NOT_VERIFIED` | 422 | The customer's KYC is not (or no longer) verified. | +| `FIAT_ACCOUNT_NOT_FOUND` | 404 | No such account, or not yours. | +| `PROVISIONING_IN_PROGRESS` | 409 | The same account is already being opened. Read it rather than retrying. | +| `KYC_NOT_CLEARED` | 422 | The customer is not KYC-verified. | +| `ISSUANCE_NOT_READY` | 422 | Verified, but the bank needs the fields in `error.details.missing_fields`. | | `CURRENCY_NOT_PRICED` | 422 | No pricing is configured for this currency yet. | -| `ACCOUNT_NOT_PROVISIONED` | 422 | The account has not finished being opened, so it has no details or history yet. | -| `RECORD_FAILED` | 500 | The account was opened but could not be recorded. **Do not retry** — contact Unigox to reconcile. | -| `BANKING_ERROR` | 502 | The banking layer refused or failed the request. | -| `BANKING_UNAVAILABLE` | 502 / 503 | The banking layer could not be reached. | -| `ENTITLEMENT_UNAVAILABLE` | 503 | We could not check your entitlements; nothing was done. | -| `HOLDER_UNAVAILABLE` | 503 | The customer could not be registered as a holder just now; nothing was done. | - -`BANKING_ERROR` and `BANKING_UNAVAILABLE` mean the request reached the banking -layer and did not complete. Both are safe to retry: issuance is idempotent per -(customer, currency, jurisdiction), so a retry either finishes the account or -returns the one that was already opened. +| `BANKING_ERROR` | 502 | The bank refused or failed the request. | +| `BANKING_UNAVAILABLE` | 503 | The bank could not be reached. Nothing was done. | + +Both banking failures are safe to retry: issuance is idempotent per (customer, +currency, jurisdiction), so a retry either finishes the account or returns the +one that was already opened. diff --git a/openapi/swagger.yaml b/openapi/swagger.yaml index 2522cda..c6824d8 100644 --- a/openapi/swagger.yaml +++ b/openapi/swagger.yaml @@ -66,16 +66,6 @@ tags: opened for that same person. There is no second identity to register and no separate customer record for banking — the person Unigox verified is the person the account belongs to. - ## Fiat accounts - - Issue dedicated fiat accounts (IBANs) for your customers and read their balances, ledger and - incoming payments. Every account is reached through the customer that holds it — which is why - these routes live here rather than under a product of their own, and why an account id on its - own will not read an account. It is an optional product that Unigox activates per partner, it - covers individuals rather than companies, and money movement is not on this API. The - [Fiat accounts guide](https://developers.unigox.com/tutorials/fiat-accounts) has the flow end - to end, what has to be switched on before you start, and the error codes. - x-page-icon: user - name: On-Ramp description: On-ramp operations (fiat to crypto) — partner initiates a buy order, vendor provides liquidity, end-user sends fiat @@ -89,6 +79,18 @@ tags: - name: Recipients description: Register and manage third parties paid by KYC-verified senders x-page-icon: users + - name: Fiat Accounts + description: | + Accounts Unigox issues to your customers, in their own name, for them to be paid into. + + A fiat account belongs to a customer you created with `POST /api/v1/partner/users` and put + through KYC, and it is addressed by its own `fiat_account_id`. Open one, show the customer + where to pay in, and read what lands on it. In v1 an account only receives: moving money off + it is not on this API, and converting it is an order. + + An optional product Unigox activates per partner. `GET /api/v1/partner/fiat-accounts/config` + answers whether it is on for you, before any customer is in scope. + x-page-icon: building-columns - name: Orders description: Order management, status tracking, and lifecycle actions x-page-icon: receipt @@ -99,13 +101,36 @@ tags: **Event model** - Three event types are delivered: + These event types are delivered: | Event type | Fired when | |---|---| | `order.status.changed` | An order transitions to a new status | | `order.refund.required` | An order's crypto is stuck in escrow and only your signature can return it | | `user.kyc.updated` | A user's KYC verification is approved or declined | + | `fiat_account.updated` | An issued fiat account changes status. No money moved. | + | `fiat_account.deposit.received` | Money arrived on an issued fiat account | + + **Payload shape — the fiat account events** + + ```json + { + "event_id": "evt_a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "event_type": "fiat_account.deposit.received", + "created_at": "2026-09-18T10:04:23Z", + "data": { + "fiat_account_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "user_uuid": "550e8400-e29b-41d4-a716-446655440000", + "transaction_id": "5f1b2c9a-1d44-4f0e-9c1a-2b5f0d7e9a31", + "amount": "500.00", + "currency": "EUR", + "order_id": null + } + } + ``` + + `order_id` is the on-ramp this deposit funded, or `null` when it matched no order. + `fiat_account.updated` carries `fiat_account_id`, `user_uuid`, `status` and `currency`. All events share the same envelope (`event_id`, `event_type`, `created_at`, `data`) and the same signature scheme. The structure of the `data` object differs per event type. @@ -408,9 +433,15 @@ paths: Current partner API omission support is explicitly available for: - `iban-sepa` - `nip-nigeria` + - `faster-payments` - `imps-neft-india` + - `alipay-wallet` + - `wechat-wallet` + - `interac-e-transfer` - For `iban-sepa` and `nip-nigeria`, partners may omit `institution_id` and the backend resolves the generic `other-bank` payment method automatically. + For `iban-sepa`, `nip-nigeria` and `faster-payments`, partners may omit `institution_id` and the backend resolves the generic `other-bank` payment method automatically. + + For `alipay-wallet`, `wechat-wallet` and `interac-e-transfer`, an omitted `institution_id` resolves to the rail's own institution (`alipay`, `wechat-pay`, `interac-e-transfers`), which goes through the same checks as if you had sent it. An Interac e-Transfer is addressed to the recipient's `email` or `phone`, not to a bank. `imps-neft-india` returns `institution_required: false` and has **no** `institution_id` field: the IFSC code encodes both bank and branch and routes the payout on its own. Required fields are `bank_name`, `ifsc_code`, `account_number`, and `full_name`. Sending `institution_id` is accepted but ignored (it resolves to the generic `imps-or-neft-transfer` method); the receiving bank comes from `bank_name` + `ifsc_code`. @@ -1698,11 +1729,12 @@ paths: Institution handling: - for most rails, `institution_id` must be provided from `/api/v1/supported/institutions` - - partners may omit `institution_id` only for the explicitly supported rails below; the backend will assign the generic `other-bank` payment method automatically + - partners may omit `institution_id` only for the explicitly supported rails below Currently supported omission rails: - - `iban-sepa` - - `nip-nigeria` + - `iban-sepa`, `nip-nigeria`, `faster-payments` — resolve to the generic `other-bank` payment method + - `imps-neft-india` — resolves to the generic `imps-or-neft-transfer` method; the bank comes from `bank_name` + `ifsc_code` + - `alipay-wallet`, `wechat-wallet`, `interac-e-transfer` — resolve to the rail's own institution (`alipay`, `wechat-pay`, `interac-e-transfers`) and go through the same checks as if you had sent it operationId: createPaymentDetails security: - ApiKeyAuth: [] @@ -2284,7 +2316,7 @@ paths: `awaiting_fiat_transfer` when the crypto is locked in escrow. At `awaiting_fiat_transfer` the partner must call `confirm-payment-sent` to advance the order — - unless the order's `fiat_funding_source` is `own_iban`. Then `next_action` is + unless the order's `fiat_funding_source` is `own_account`. Then `next_action` is `deposit_to_user_account`: the customer transfers the order's fiat amount into the fiat account issued to them, that transfer confirms the payment, and `confirm-payment-sent` is not used. If the order type is `payment_request`, call `submit-payer-details` first before confirming. @@ -4725,20 +4757,17 @@ paths: /api/v1/partner/fiat-accounts/config: get: tags: - - User Management - summary: What you can offer + - Fiat Accounts + summary: What fiat accounts you can offer operationId: getFiatAccountsConfig description: | - Whether this partner may use fiat accounts right now, which currencies an account may be - denominated in, and which jurisdictions each currency can be issued from. - - The one route outside the customer tree, and deliberately: it answers what you may offer AT - ALL, before any customer is in scope. It is not an account. + Whether you may issue fiat accounts right now, which currencies an account may be + denominated in, and which jurisdictions each currency can be issued from. The one fiat + account route that names no customer. - Never errors on entitlement: when the product is not active it answers `200` with - `enabled: false` and a `disabled_reason`, so a console can render the reason rather than a - failure. `issues_accounts: false` with `enabled: true` is a real shape — the partner may put - customers through verification but not open accounts for them. + Entitlement is never an error here: when the product is not active it answers `200` with + `enabled: false` and a `disabled_reason`. `issues_accounts: false` with `enabled: true` + means you may read accounts but not open them. security: - ApiKeyAuth: [] responses: @@ -4747,7 +4776,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/FiatConfigResponse' + $ref: '#/components/schemas/FiatAccountConfigResponse' '401': description: Missing or invalid credential content: @@ -4755,179 +4784,36 @@ paths: schema: $ref: '#/components/schemas/PartnerErrorResponse' - /api/v1/partner/users/{user_uuid}/identity: - get: - tags: - - User Management - summary: What identity we hold, and what is still needed - operationId: getFiatCustomerIdentity - description: | - What Unigox KYC already established about this customer, and `missing` — the fields the - banking layer still needs before an account can be opened. `ready: true` means the - identification submission will be accepted with an empty body. - - A read: it creates nothing, calls no one and stays available when the product is switched - off, so a partner can always see WHY a customer cannot be issued an account. - - The document number is never returned — only its last four digits. - security: - - ApiKeyAuth: [] - parameters: - - $ref: '#/components/parameters/FiatUserUuid' - responses: - '200': - description: Known identity and remaining gaps - content: - application/json: - schema: - $ref: '#/components/schemas/FiatIdentityResponse' - '404': - description: No such customer, or not yours (`CUSTOMER_NOT_FOUND`) - content: - application/json: - schema: - $ref: '#/components/schemas/PartnerErrorResponse' - - /api/v1/partner/users/{user_uuid}/identification: + /api/v1/partner/fiat-accounts: get: tags: - - User Management - summary: Verification status of the account holder - operationId: getFiatIdentification - description: | - The holder's current verification state. Poll this until `can_open_accounts` is `true`. - - A `GET` on purpose: checking a verification changes nothing, so it is safe to retry and safe - to run on a schedule. Where the identity has already reached the banking layer this re-reads - their verdict; before that it answers from the holder record. - security: - - ApiKeyAuth: [] - parameters: - - $ref: '#/components/parameters/FiatUserUuid' - responses: - '200': - description: Holder status - content: - application/json: - schema: - $ref: '#/components/schemas/FiatHolderResponse' - '404': - description: | - No such customer, or not yours (`CUSTOMER_NOT_FOUND`), or the customer has not been - submitted for verification yet (`ACCOUNT_HOLDER_NOT_FOUND`) — polling never creates a - holder record. - content: - application/json: - schema: - $ref: '#/components/schemas/PartnerErrorResponse' - '502': - description: The banking layer could not be read - content: - application/json: - schema: - $ref: '#/components/schemas/PartnerErrorResponse' - post: - tags: - - User Management - summary: Submit the customer's identity - operationId: submitFiatIdentification + - Fiat Accounts + summary: List a customer's fiat accounts + operationId: listFiatAccounts description: | - Submits the customer's identity to the banking layer, registering them as an account holder - if this is the first call. - - **Every body field is optional.** What Unigox verified at KYC is always preferred over what - is sent here, so send only what `GET /users/{user_uuid}/identity` reported as `missing`. The - name, the country of residence and the document are taken from the verification and cannot - be overridden — the identity a bank opens an account on has to be the one that was verified. - - The customer must be KYC-verified by Unigox first. The document number is used for the - submission and is not stored; only its last four digits are kept. + Every fiat account issued to one of your customers. A customer with none yet answers an + empty list. - Idempotent: a customer already submitted answers `200` with their current holder record and - makes no second submission. + Only the last four digits of the identifier travel here. Read a single account for the + pay-in details a customer needs. security: - ApiKeyAuth: [] parameters: - - $ref: '#/components/parameters/FiatUserUuid' - requestBody: - required: false - content: - application/json: - schema: - $ref: '#/components/schemas/FiatIdentificationRequest' - responses: - '200': - description: Submitted (or already submitted) - content: - application/json: - schema: - $ref: '#/components/schemas/FiatHolderResponse' - '400': - description: | - Identity gaps (`MISSING_FIELDS`, with `error.details.missing_fields`) or a document type - outside the accepted set (`INVALID_DOCUMENT_TYPE`). - content: - application/json: - schema: - $ref: '#/components/schemas/PartnerErrorResponse' - '403': - description: Not entitled (`PRODUCT_NOT_ACTIVATED`, `ISSUANCE_DISABLED`) - content: - application/json: - schema: - $ref: '#/components/schemas/PartnerErrorResponse' - '404': - description: No such customer, or not yours (`CUSTOMER_NOT_FOUND`) - content: - application/json: - schema: - $ref: '#/components/schemas/PartnerErrorResponse' - '409': - description: | - The banking layer matched this person to an identity another holder already owns - (`IDENTIFICATION_ALREADY_LINKED`), or this customer is already being registered as a - holder by a request in flight (`HOLDER_REGISTRATION_IN_PROGRESS`) — retry once it - settles. - content: - application/json: - schema: - $ref: '#/components/schemas/PartnerErrorResponse' - '422': - description: The customer is not KYC-verified (`CUSTOMER_NOT_VERIFIED`) - content: - application/json: - schema: - $ref: '#/components/schemas/PartnerErrorResponse' - '502': - description: The banking layer refused or failed the submission - content: - application/json: - schema: - $ref: '#/components/schemas/PartnerErrorResponse' - - /api/v1/partner/users/{user_uuid}/fiat-accounts: - get: - tags: - - User Management - summary: This customer's accounts - operationId: listFiatAccountsForUser - description: | - Every account issued to this customer. A read: a customer with no issuance yet has no - accounts, which is an empty list rather than a reason to create anything. - - Only the last four digits of the identifier travel here — fetch a single account for the - full details a payer needs. - security: - - ApiKeyAuth: [] - parameters: - - $ref: '#/components/parameters/FiatUserUuid' + - name: user_uuid + in: query + required: true + description: The customer whose accounts to list. + schema: + type: string + format: uuid + example: "550e8400-e29b-41d4-a716-446655440000" responses: '200': description: Accounts content: application/json: schema: - $ref: '#/components/schemas/FiatAccountsResponse' + $ref: '#/components/schemas/FiatAccountListResponse' '404': description: No such customer, or not yours (`CUSTOMER_NOT_FOUND`) content: @@ -4936,52 +4822,50 @@ paths: $ref: '#/components/schemas/PartnerErrorResponse' post: tags: - - User Management - summary: Issue an account + - Fiat Accounts + summary: Issue a fiat account operationId: issueFiatAccount description: | - Opens a fiat account (IBAN) for the customer, registering them as an account holder first if - needed. The holder must already be approved — see - `GET /users/{user_uuid}/identification`. + Opens a fiat account for one of your customers. The customer must be KYC-verified by + Unigox; everything the bank needs about them comes from that verification, so there is no + identity to submit here. + + The account is opened asynchronously. It answers `201` with `status: "pending"` and no + pay-in details; watch for `fiat_account.updated` — or poll the account — until it reads + `active`, and only then tell the customer where to transfer. - **Idempotent per (customer, currency, jurisdiction).** A repeat answers `201` with the same - account and `created: false`; it does not open a second one. One customer may hold accounts - in several currencies, and in several jurisdictions of the same currency — `EUR/NL` and - `EUR/MT` are two accounts, each opened by naming its `issuer_country`. + **Idempotent per (user_uuid, currency, issuer_country).** A repeat answers `201` with the + same account and `created: false`. - Two requests racing for the same account answer `409 PROVISIONING_IN_PROGRESS`. Retry once - it settles rather than treating it as a failure. + When the bank needs a field the KYC record does not have, this answers `422 + ISSUANCE_NOT_READY` and names those fields in `error.details.missing_fields`. They are KYC + field names: fill them with `PATCH /api/v1/partner/users/{user_uuid}/kyc` and post again. security: - ApiKeyAuth: [] - parameters: - - $ref: '#/components/parameters/FiatUserUuid' requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/FiatIssueAccountRequest' + $ref: '#/components/schemas/FiatAccountCreateRequest' responses: '201': description: | - Account issued, or the existing one for this (customer, currency, jurisdiction). - `created` distinguishes the two. + Opened, or the existing account for this (customer, currency, jurisdiction). `created` + distinguishes the two. content: application/json: schema: - $ref: '#/components/schemas/FiatAccountIssuedResponse' + $ref: '#/components/schemas/FiatAccountResponse' '400': description: | - `UNSUPPORTED_CURRENCY`, `UNSUPPORTED_ISSUER_COUNTRY`, `POSTAL_CODE_REQUIRED` or - `INVALID_POSTAL_CODE`. + `UNSUPPORTED_CURRENCY`, `UNSUPPORTED_ISSUER_COUNTRY` or `POSTAL_CODE_REQUIRED`. content: application/json: schema: $ref: '#/components/schemas/PartnerErrorResponse' '403': - description: | - Not entitled (`PRODUCT_NOT_ACTIVATED`, `ISSUANCE_NOT_GRANTED`, `ISSUANCE_DISABLED`) or - the currency is outside this customer's issuance (`CURRENCY_NOT_PERMITTED`). + description: Not entitled (`PRODUCT_NOT_ACTIVATED`, `ISSUANCE_NOT_GRANTED`) content: application/json: schema: @@ -4993,57 +4877,46 @@ paths: schema: $ref: '#/components/schemas/PartnerErrorResponse' '409': - description: | - The same account is already being opened (`PROVISIONING_IN_PROGRESS`), or this customer - is already being registered as a holder by a request in flight - (`HOLDER_REGISTRATION_IN_PROGRESS`). Both settle on their own — retry. + description: The same account is already being opened (`PROVISIONING_IN_PROGRESS`) content: application/json: schema: $ref: '#/components/schemas/PartnerErrorResponse' '422': description: | - The holder is not approved or not submitted (`CLIENT_NOT_APPROVED`, - `IDENTIFICATION_MISSING`), the customer is no longer verified - (`CUSTOMER_NOT_VERIFIED`), or the currency is not priced (`CURRENCY_NOT_PRICED`). + The customer is not verified (`KYC_NOT_CLEARED`), the bank needs fields the KYC record + does not have (`ISSUANCE_NOT_READY`, with `error.details.missing_fields`), or the + currency has no pricing (`CURRENCY_NOT_PRICED`). content: application/json: schema: $ref: '#/components/schemas/PartnerErrorResponse' - '502': - description: The banking layer refused to open the account - content: - application/json: - schema: - $ref: '#/components/schemas/PartnerErrorResponse' - '500': - description: | - The account was opened at the banking layer but the local record write failed — do not - retry (`RECORD_FAILED`); contact Unigox so it can be reconciled. + '503': + description: The bank could not be reached (`BANKING_UNAVAILABLE`). Safe to retry. content: application/json: schema: $ref: '#/components/schemas/PartnerErrorResponse' - /api/v1/partner/users/{user_uuid}/fiat-accounts/{account_id}: + /api/v1/partner/fiat-accounts/{fiat_account_id}: get: tags: - - User Management - summary: One account, with balances + - Fiat Accounts + summary: One fiat account operationId: getFiatAccount description: | - The full account details a payer needs — including the complete IBAN or account number — the - holder it belongs to, and the live balances. + One account: its status, its live balances, and — once it is `active` — the pay-in details + to show the customer. - An account is always addressed through the customer that holds it. One that belongs to a - different customer answers `404`, the same as one that does not exist. + Those details are shaped by the rail the account is on: `iban` and `bic` for a euro (SEPA) + account, `account_number` and `sort_code` for a sterling (Faster Payments) one. Keys that + do not apply are absent rather than empty. - `balances_unavailable: true` means the balance read failed — the balances are reported as - unavailable rather than as zero, because a zero a partner acts on is worse than a gap. + `balances_unavailable: true` means the balance read failed. Balances are reported as + unavailable rather than as zero: a zero you act on is worse than a gap. security: - ApiKeyAuth: [] parameters: - - $ref: '#/components/parameters/FiatUserUuid' - $ref: '#/components/parameters/FiatAccountId' responses: '200': @@ -5051,114 +4924,55 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/FiatAccountDetailResponse' + $ref: '#/components/schemas/FiatAccountResponse' '404': - description: | - No such customer, or not yours (`CUSTOMER_NOT_FOUND`); or no such account under that - customer (`FIAT_ACCOUNT_NOT_FOUND`). An account that exists but belongs to a DIFFERENT - customer answers the same way — it is not addressable through this path. - content: - application/json: - schema: - $ref: '#/components/schemas/PartnerErrorResponse' - '422': - description: The account has not finished being opened (`ACCOUNT_NOT_PROVISIONED`) + description: No such account, or not yours (`FIAT_ACCOUNT_NOT_FOUND`) content: application/json: schema: $ref: '#/components/schemas/PartnerErrorResponse' - /api/v1/partner/users/{user_uuid}/fiat-accounts/{account_id}/ledger: + /api/v1/partner/fiat-accounts/{fiat_account_id}/transactions: get: tags: - - User Management - summary: Transaction history - operationId: getFiatAccountLedger + - Fiat Accounts + summary: Money that moved on a fiat account + operationId: getFiatAccountTransactions description: | - One page of ledger entries for the account. + One page of the money that moved on the account, newest first. v1 is receive-only, so every + row is a `credit`: a transfer the customer's payer sent to the account. - `pagination` is present only when the upstream page metadata is available. **Its absence - means unknown, not "one page"** — fall back to judging by the length of the page you got. - security: - - ApiKeyAuth: [] - parameters: - - $ref: '#/components/parameters/FiatUserUuid' - - $ref: '#/components/parameters/FiatAccountId' - - name: page - in: query - required: false - description: 1-based page number. Omitted or unparseable means the first page. - schema: - type: integer - minimum: 1 - example: 2 - responses: - '200': - description: Ledger page - content: - application/json: - schema: - $ref: '#/components/schemas/FiatLedgerResponse' - '404': - description: | - No such customer, or not yours (`CUSTOMER_NOT_FOUND`); or no such account under that - customer (`FIAT_ACCOUNT_NOT_FOUND`). An account that exists but belongs to a DIFFERENT - customer answers the same way — it is not addressable through this path. - content: - application/json: - schema: - $ref: '#/components/schemas/PartnerErrorResponse' - '422': - description: The account has not finished being opened (`ACCOUNT_NOT_PROVISIONED`) - content: - application/json: - schema: - $ref: '#/components/schemas/PartnerErrorResponse' - '502': - description: The banking layer could not be read (`BANKING_ERROR` / `BANKING_UNAVAILABLE`) - content: - application/json: - schema: - $ref: '#/components/schemas/PartnerErrorResponse' + A credit that funded an on-ramp carries that order in `order_id`; one that matched no order + carries `null` and simply sits on the account. - /api/v1/partner/users/{user_uuid}/fiat-accounts/{account_id}/payments: - get: - tags: - - User Management - summary: Payment records - operationId: getFiatAccountPayments - description: | - One page of payment records for the account — the incoming payments that credited it. - Same pagination caveat as the ledger. + Paging is `page`. `has_more` says whether another page exists. security: - ApiKeyAuth: [] parameters: - - $ref: '#/components/parameters/FiatUserUuid' - $ref: '#/components/parameters/FiatAccountId' - name: page in: query required: false + description: 1-based page number. Omitted or unparseable means the first page. schema: type: integer minimum: 1 + example: 1 responses: '200': - description: Payments page + description: Transactions page content: application/json: schema: - $ref: '#/components/schemas/FiatPaymentsResponse' + $ref: '#/components/schemas/FiatTransactionsResponse' '404': - description: | - No such customer, or not yours (`CUSTOMER_NOT_FOUND`); or no such account under that - customer (`FIAT_ACCOUNT_NOT_FOUND`). An account that exists but belongs to a DIFFERENT - customer answers the same way — it is not addressable through this path. + description: No such account, or not yours (`FIAT_ACCOUNT_NOT_FOUND`) content: application/json: schema: $ref: '#/components/schemas/PartnerErrorResponse' - '502': - description: The banking layer could not be read + '503': + description: The bank could not be read (`BANKING_UNAVAILABLE`) content: application/json: schema: @@ -5857,6 +5671,25 @@ components: format: date-time description: Last update timestamp for KYC status example: "2026-01-06T14:30:00Z" + fiat_account_issuance: + type: object + description: | + Whether a fiat account can be opened for this customer, and what the bank is still + short of if not. It lives here because there is no second identity to register for + an account: the gaps are gaps in this customer's KYC record. + properties: + ready: + type: boolean + description: True when `POST /api/v1/partner/fiat-accounts` will be accepted for this customer. + example: false + missing_fields: + type: array + items: { type: string } + description: | + The fields the bank needs that the KYC record does not have, in the names + `PATCH /api/v1/partner/users/{user_uuid}/kyc` takes: `address`, `city`, + `postal_code`, `dob`, `id_number`, `id_type`. + example: ["address", "postal_code"] payment_profiles: type: array items: @@ -6894,7 +6727,7 @@ components: - `authorize_refund` — the order ended without a payout and its crypto is still in escrow; only your signature can send it back. Note that this appears on an order whose `status` is `cancelled`, which is the one case where a terminal status still needs something from you - - `deposit_to_user_account` — an on-ramp order with `fiat_funding_source: own_iban` is waiting for the + - `deposit_to_user_account` — an on-ramp order with `fiat_funding_source: own_account` is waiting for the customer to transfer the order's fiat amount into the fiat account issued to them. The transfer itself confirms the payment; there is nothing to call - `null` — no required action (waiting for buyer or system) @@ -6967,7 +6800,7 @@ components: rather than assuming a cancelled order is finished with. `next_action` is `authorize_refund` and `action_required` names the endpoint at the same time. - On-ramp orders with `fiat_funding_source: own_iban` never offer `confirm-payment-sent`: the + On-ramp orders with `fiat_funding_source: own_account` never offer `confirm-payment-sent`: the transfer into the customer's own account is the confirmation. Custody outranks the mapping above: on a **widget order** — one your customer created through the embedded widget under your `widgetKey` — this is `[]` at every status, @@ -6976,17 +6809,26 @@ components: example: ["authorize-crypto-transfer", "cancel"] fiat_funding_source: type: string - enum: [vendor_details, own_iban] + enum: [vendor_details, own_account] description: | On-ramp orders only. Where the fiat for this order is paid: - `vendor_details` — to the vendor's bank account in `vendor_payment_details`; then call `confirm-payment-sent`. - - `own_iban` — into the fiat account you issued to the customer. There are no - `vendor_payment_details` and no `confirm-payment-sent`: the transfer landing on the customer's - account is matched to the order by amount and the order proceeds on its own. + - `own_account` — into the fiat account you issued to the customer, named by + `fiat_account_id`. There are no `vendor_payment_details` and no `confirm-payment-sent`: + the transfer landing on the customer's account is matched to the order by amount and the + order proceeds on its own. Read it on every order rather than assuming one or the other. - example: own_iban + example: own_account + fiat_account_id: + type: string + format: uuid + description: | + On an `own_account` order: the fiat account the customer transfers into. The same id the + Fiat Accounts endpoints take, so the deposit that funds this order can be found on that + account's transactions carrying this order's id. + example: "7c9e6679-7425-40de-944b-e07fc1f90ae7" crypto_transfer_authorization_seconds_left: type: integer format: int64 @@ -7159,7 +7001,7 @@ components: **On-ramp statuses (fiat → crypto):** - `awaiting_liquidity_provider` — order initiated, waiting for vendor acceptance - `awaiting_vendor_escrow_funding` — vendor accepted, funding escrow with crypto - - `awaiting_fiat_transfer` — crypto locked in escrow; the partner pays the vendor and confirms payment sent (`fiat_funding_source: vendor_details`), or the customer transfers into their own fiat account (`own_iban`) + - `awaiting_fiat_transfer` — crypto locked in escrow; the partner pays the vendor and confirms payment sent (`fiat_funding_source: vendor_details`), or the customer transfers into their own fiat account (`own_account`) - `fiat_transfer_pending` — fiat payment confirmed, awaiting vendor confirmation of receipt - `completed` — vendor confirmed fiat received, crypto released to end-user wallet - `cancelled` — order cancelled @@ -7261,7 +7103,7 @@ components: description: | Event type. `order.status.changed` for a status transition, `order.refund.required` for an order whose crypto is stuck in escrow awaiting your signature (see the Webhooks section). - enum: [order.status.changed, order.refund.required] + enum: [order.status.changed, order.refund.required, fiat_account.updated, fiat_account.deposit.received] example: "order.status.changed" created_at: type: string @@ -7547,156 +7389,160 @@ components: current_rate: 1545.00 # ---- Fiat Accounts ---- - FiatConfig: + FiatAccountConfigResponse: type: object + required: [success, data] properties: - enabled: - type: boolean - description: Whether this partner may use fiat accounts at all. - example: true - issues_accounts: - type: boolean + success: { type: boolean, example: true } + data: + type: object + properties: + enabled: + type: boolean + description: Whether you may use fiat accounts at all. + example: true + issues_accounts: + type: boolean + description: | + Whether you may OPEN accounts, as opposed to only reading the ones you have. + example: true + disabled_reason: + type: string + description: Why the product is unavailable. Present only when `enabled` is false. + example: PRODUCT_NOT_ACTIVATED + currencies: + type: array + items: { type: string } + description: ISO 4217 currencies an account may be denominated in. + example: ["EUR", "GBP"] + issuers: + type: object + additionalProperties: + type: array + items: { type: string } + description: Currency to issuing jurisdictions (ISO 3166-1 alpha-2), default first. + example: { "EUR": ["NL", "MT"], "GBP": ["GB"] } + + FiatAccountCreateRequest: + type: object + required: [user_uuid, currency] + properties: + user_uuid: + type: string + format: uuid + description: One of your customers, KYC-verified by Unigox. + example: "550e8400-e29b-41d4-a716-446655440000" + currency: + type: string + description: One of `config.currencies`. + example: EUR + issuer_country: + type: string description: | - Whether this partner may OPEN accounts, as opposed to only putting customers through - verification. `false` with `enabled: true` is a real product shape, not a degraded one. - example: true - disabled_reason: + ISO 3166-1 alpha-2, one of `config.issuers[currency]`. Omitted, the currency's default + jurisdiction is used. + example: NL + postal_code: type: string description: | - Why the surface is unavailable, when `enabled` is false because of something about this - partner. Absent when the product is simply switched off platform-wide. - example: PRODUCT_NOT_ACTIVATED - currencies: - type: array - items: { type: string } - description: ISO 4217 currencies an account may be denominated in, narrowed to what this partner's plan prices. - example: ["EUR", "GBP"] - issuers: - type: object - additionalProperties: - type: array - items: { type: string } - description: Currency → issuing jurisdictions (ISO 3166-1 alpha-2), default first. - example: { "EUR": ["NL", "MT"], "GBP": ["GB"] } - postal_code_issuers: - type: array - items: { type: string } - description: Jurisdictions that will not issue unless a postal code is on the identity record. - example: ["NL"] + Only needed when the chosen jurisdiction requires one and the customer's KYC record has + none. Sent here, it is written onto that KYC record. + example: "1011 AB" FiatAccount: type: object description: | - One issued account. Only the last four digits of the identifier travel in list views; the - single-account view carries what a payer needs in full. + One issued account. The pay-in fields appear once `status` is `active`, and only the ones + the account's rail uses. - No field identifies the bank or banking platform Unigox routes through. `bank_name` and - `bic` describe the account itself, which is different — those are for display to a payer. + Nothing here names the bank or banking platform Unigox routes through. `bank_name` and + `bic` describe the account a payer sends money to, which is different — those are for the + customer to see. properties: - id: + fiat_account_id: type: string + format: uuid description: | - Opaque account id, carrying its holder kind: `retail_412`. This is what every - `/users/{user_uuid}/fiat-accounts/{account_id}` route takes. Pass it back verbatim and - do not parse it — the prefix is part of the id, not decoration, and ids are not unique - across holder kinds without it. - example: "retail_412" - holder_type: - type: string - enum: [retail] - description: | - The kind of holder this account belongs to. `retail` — an individual you onboarded - through KYC — is the only kind addressable on this API. Published so a client can tell - the kinds apart if another becomes available; do not assume it is the only value - forever. - example: retail - holder_id: + The account's id, and the only one. It is what every `/fiat-accounts/{fiat_account_id}` + route takes, what a webhook names the account by, and what an order funded from this + account carries. + example: "7c9e6679-7425-40de-944b-e07fc1f90ae7" + user_uuid: type: string - description: | - The holder — the customer's `user_uuid`, the same value that appears in the path. Sent - so an account object is self-describing when it is passed around on its own. + format: uuid + description: The customer who holds the account. + example: "550e8400-e29b-41d4-a716-446655440000" currency: type: string example: EUR issuer_country: type: string - description: The jurisdiction the account was actually issued in, read off the IBAN. + description: The jurisdiction the account was issued in. example: NL status: type: string + enum: [pending, active, failed, closed] description: | - `active`, `pending`, `closed` or `unknown`. A CLOSED account stays readable: closing - retires the IBAN without removing the account, its history, or any money still behind it. + `pending` — being opened, no pay-in details yet, do not send the customer to it. + `active` — ready, and the pay-in details below are on this response. + `failed` — the bank refused; this account will not become usable. + `closed` — retired; its history stays readable and new deposits will not credit it. example: active + created: + type: boolean + description: | + On the issue response only: whether THIS request opened the account. `false` means it + already existed and is being handed back. + example: true iban: type: string - description: Single-account view only — the list views carry `iban_last4`. + description: Euro (SEPA) accounts. Pay-in detail. example: "NL91ABNA0417164300" - iban_last4: + bic: type: string - example: "8827" + description: Euro (SEPA) accounts. Pay-in detail. + example: ABNANL2A account_number: type: string - description: Single-account view only, on a non-IBAN (GB sterling) account. - account_number_last4: - type: string - description: Present instead of `iban_last4` on a non-IBAN account (a GB sterling one). - example: "4471" + description: Sterling (Faster Payments) accounts. Pay-in detail. + example: "25976129" sort_code: type: string - description: Present on a GB sterling account. - example: "04-00-04" - holder_name: + description: Sterling (Faster Payments) accounts. Pay-in detail. + example: "23-08-01" + iban_last4: type: string - description: The name the account is held in. + description: List views, where the full identifier does not travel. + example: "8827" + account_number_last4: + type: string + description: List views of a sterling account. + example: "4471" bank_name: type: string - description: The bank a payer should be shown, resolved from the BIC or the IBAN's bank code. + description: The bank to show the customer. example: ABN AMRO - bic: - type: string - example: ABNANL2A - holder_full_name: - type: string - description: List views only — who the account belongs to, so a list needs no second call. - holder_email: - type: string - holder_status: + holder_name: type: string + description: The name the account is held in. + example: Maria ZALISHCHUK + balances: + type: array + description: Single-account view only. + items: + $ref: '#/components/schemas/FiatBalance' + balances_unavailable: + type: boolean + description: | + Single-account view only. True when the balance read failed — the balances are reported + as unavailable rather than as zero. + example: false created_at: type: string + format: date-time + example: "2026-09-18T10:00:00Z" - FiatAccountHolder: - type: object - description: | - The customer, as the account holder. There is no separate identity here: `user_uuid` is the - customer created with `POST /api/v1/partner/users`, and the names and country are the ones - Unigox KYC verified. - properties: - user_uuid: - type: string - format: uuid - first_name: { type: string } - last_name: { type: string } - full_name: { type: string } - email: { type: string } - phone: { type: string } - country_of_residence: { type: string, example: GB } - kyc_status: - type: string - description: Unigox's own verdict on the customer. - example: approved - status: - type: string - description: 'Holder lifecycle: `draft`, `pending_review`, `approved`, `rejected`.' - example: approved - can_open_accounts: - type: boolean - description: Whether an account may be opened for this holder right now. Poll on this. - example: true - created_at: { type: string } - - FiatBalanceLine: + FiatBalance: type: object properties: currency: { type: string, example: EUR } @@ -7704,82 +7550,15 @@ components: pending: { type: string, example: "0.00" } total: { type: string, example: "1250.00" } - FiatLedgerEntry: - type: object - properties: - id: { type: string } - type: { type: string } - category: { type: string } - amount: { type: string, example: "500.00" } - currency: { type: string, example: EUR } - reference: - type: string - description: The entry's own upstream reference, for reconciliation. - note: { type: string } - created_at: - type: integer - format: int64 - description: Unix seconds. - - FiatPayment: - type: object - properties: - id: { type: string } - amount: { type: string, example: "500.00" } - currency: { type: string, example: EUR } - kind: { type: string } - status: { type: string } - recipient: { type: string } - description: { type: string } - payment_rail: { type: string } - iban: { type: string } - bic: { type: string } - created_at: { type: integer, format: int64 } - updated_at: { type: integer, format: int64 } - - FiatPagination: - type: object - description: | - Upstream page metadata, present only when it is available. **Absence means unknown, not - "one page"** — fall back to the length of the page you received. - properties: - current_page: { type: integer } - per_page: { type: integer } - total_pages: { type: integer } - total_count: { type: integer } - - FiatIdentity: - type: object - description: What Unigox KYC established. The document number is never returned — only its last four digits. - properties: - first_name: { type: string } - last_name: { type: string } - email: { type: string } - phone: { type: string } - country_of_residence: { type: string } - address: { type: string } - city: { type: string } - postal_code: { type: string } - birthdate: { type: string, example: "1990-04-17" } - document_type: { type: string, example: PASSPORT } - document_last4: { type: string, example: "6789" } - sources: - type: object - additionalProperties: { type: string } - description: Per-field provenance, so a caller can tell a verified value from a declared one. - - FiatConfigResponse: + FiatAccountResponse: type: object required: [success, data] properties: success: { type: boolean, example: true } data: - type: object - properties: - config: - $ref: '#/components/schemas/FiatConfig' + $ref: '#/components/schemas/FiatAccount' - FiatAccountsResponse: + FiatAccountListResponse: type: object required: [success, data] properties: @@ -7787,78 +7566,48 @@ components: data: type: object properties: - accounts: + fiat_accounts: type: array items: $ref: '#/components/schemas/FiatAccount' - FiatAccountDetailResponse: - type: object - required: [success, data] - properties: - success: { type: boolean, example: true } - data: - type: object - properties: - account: - $ref: '#/components/schemas/FiatAccount' - holder: - $ref: '#/components/schemas/FiatAccountHolder' - balances: - type: array - items: - $ref: '#/components/schemas/FiatBalanceLine' - balances_unavailable: - type: boolean - description: | - True when the balance read failed. The balances are reported as unavailable rather - than as zero — a zero a partner acts on is worse than a gap. - - FiatAccountIssuedResponse: - type: object - required: [success, data] - properties: - success: { type: boolean, example: true } - data: - type: object - properties: - account: - $ref: '#/components/schemas/FiatAccount' - created: - type: boolean - description: | - Whether THIS request opened the account. `false` means it already existed and is - being handed back — the endpoint is idempotent per (customer, currency, - jurisdiction). - example: true - - FiatHolderResponse: + FiatTransaction: type: object - required: [success, data] - properties: - success: { type: boolean, example: true } - data: - type: object - properties: - holder: - $ref: '#/components/schemas/FiatAccountHolder' - - FiatLedgerResponse: - type: object - required: [success, data] + description: One movement of money on a fiat account. properties: - success: { type: boolean, example: true } - data: - type: object - properties: - entries: - type: array - items: - $ref: '#/components/schemas/FiatLedgerEntry' - pagination: - $ref: '#/components/schemas/FiatPagination' + transaction_id: + type: string + description: The transaction's id, and the id a deposit webhook names it by. + example: "5f1b2c9a-1d44-4f0e-9c1a-2b5f0d7e9a31" + type: + type: string + enum: [credit] + description: | + `credit` — money arrived on the account. v1 is receive-only, so this is the only value. + example: credit + amount: + type: string + example: "500.00" + currency: + type: string + example: EUR + order_id: + type: string + nullable: true + description: | + The on-ramp order this credit funded, or `null` when it matched no order and simply + stays on the account. + example: "b2c3d4e5-f6a7-8901-bcde-f12345678901" + description: + type: string + description: What the payer sent the transfer with, when it carried anything. + example: "Invoice 2026-114" + created_at: + type: string + format: date-time + example: "2026-09-18T10:04:22Z" - FiatPaymentsResponse: + FiatTransactionsResponse: type: object required: [success, data] properties: @@ -7866,95 +7615,27 @@ components: data: type: object properties: - payments: + transactions: type: array items: - $ref: '#/components/schemas/FiatPayment' - pagination: - $ref: '#/components/schemas/FiatPagination' - - FiatIdentityResponse: - type: object - required: [success, data] - properties: - success: { type: boolean, example: true } - data: - type: object - properties: - identity: - $ref: '#/components/schemas/FiatIdentity' - missing: - type: array - items: { type: string } - description: The fields the banking layer still needs. Empty means nothing is missing. - example: ["address", "birthdate"] - ready: + $ref: '#/components/schemas/FiatTransaction' + has_more: type: boolean - description: True when `missing` is empty — the identification submission will be accepted with an empty body. - - FiatIdentificationRequest: - type: object - description: | - Every field is optional. What Unigox verified at KYC is preferred over anything sent here, - so send only what `GET /users/{user_uuid}/identity` reported as missing. The name, the country - of residence and the document are taken from the verification and cannot be overridden. - properties: - address: { type: string, example: "10 Downing Street" } - city: { type: string, example: London } - birthdate: { type: string, example: "1990-04-17" } - document_type: - type: string - enum: [PASSPORT, NATIONAL_ID, DRIVERS_LICENCE, WORK_PERMIT] - document_number: - type: string - description: Used for the submission and not stored; only the last four digits are kept. - email: { type: string } - country_of_residence: { type: string, example: GB } - - FiatIssueAccountRequest: - type: object - required: [currency] - properties: - currency: - type: string - description: ISO 4217; must be one `GET /fiat-accounts/config` offers. Upper-cased server-side. - example: EUR - issuer_country: - type: string - description: | - Issuing jurisdiction (ISO 3166-1 alpha-2). Omit for the currency's default; supplied, it - must be one of that currency's `issuers`. - example: NL - postal_code: - type: string - description: Required only when the chosen jurisdiction is in `postal_code_issuers`; ignored otherwise. - example: "1011 AB" + description: Whether another page exists after this one. + example: false parameters: - FiatUserUuid: - name: user_uuid - in: path - required: true - description: | - Public uuid of a customer owned by the authenticated partner — the id returned by - `POST /api/v1/partner/users`. A customer you do not own answers `404`, indistinguishable - from one that does not exist. - schema: - type: string - format: uuid - example: "550e8400-e29b-41d4-a716-446655440000" FiatAccountId: - name: account_id + name: fiat_account_id in: path required: true description: | - Opaque id of a fiat account, as returned in `FiatAccount.id` — `retail_412`. An id that - names no account, one that is not yours, and one that belongs to a different customer than - the path names all answer `404`; they are indistinguishable on purpose. + The account's id, as returned in `fiat_account_id`. An id that names no account and one + that is not yours both answer `404`; they are indistinguishable on purpose. schema: type: string - example: "retail_412" - + format: uuid + example: "7c9e6679-7425-40de-944b-e07fc1f90ae7" securitySchemes: ApiKeyAuth: type: apiKey