From da68fc9b8e1002f3d1c541ad0a17ab42eba2898b Mon Sep 17 00:00:00 2001 From: hallstain Date: Tue, 15 Sep 2026 16:52:16 +0300 Subject: [PATCH 1/7] Say that an Interac destination needs no institution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog has reported institution_required:false for interac-e-transfer while creating a destination without one was refused. The backend now resolves an omitted institution to interac-e-transfers; the changelog says so, and both omission lists in the reference name every rail that accepts it — they had stopped at iban-sepa and nip-nigeria, missing faster-payments and the Chinese wallets as well. Co-Authored-By: Claude Opus 5 (1M context) --- changelog.md | 8 ++++++++ openapi/swagger.yaml | 15 +++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/changelog.md b/changelog.md index c2b615a..ab5eb7b 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,14 @@ Notable changes to the Unigox partner API, newest first. +## 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/openapi/swagger.yaml b/openapi/swagger.yaml index 2522cda..77387fa 100644 --- a/openapi/swagger.yaml +++ b/openapi/swagger.yaml @@ -408,9 +408,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 +1704,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: [] From 6dd076d8c68b2842089d461a3e18ba237e341875 Mon Sep 17 00:00:00 2001 From: hallstain Date: Wed, 16 Sep 2026 15:53:16 +0300 Subject: [PATCH 2/7] Say what the fiat account ledger and payments actually list The payments route was documented as the incoming payments that credited the account. It lists bank transfers in both directions, the way the bank returns them, so `kind` is `incoming` or `outgoing` and `recipient`, `iban` and `bic` name the other side of the transfer. - Ledger: every balance change, not only transfers. Its `reference` is the id of the operation behind the entry, which for a transfer is the payment `id`. That is how the two lists connect. - Publish the `kind` and `status` values, and say that ledger `type` and `category` are open lists. - Payments can answer 422 ACCOUNT_NOT_PROVISIONED like the ledger does. - Name both operations as fiat account reads, so they no longer read as the customer's whole transaction history on the User Management page. Co-Authored-By: Claude Opus 5 (1M context) --- fiat-accounts.md | 29 +++++++--- openapi/swagger.yaml | 130 ++++++++++++++++++++++++++++++++----------- 2 files changed, 120 insertions(+), 39 deletions(-) diff --git a/fiat-accounts.md b/fiat-accounts.md index e564321..1cfca6d 100644 --- a/fiat-accounts.md +++ b/fiat-accounts.md @@ -5,8 +5,8 @@ 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. +money, and you can read the balance, the ledger and the bank transfers in and out +of the account. **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 @@ -262,8 +262,8 @@ settles rather than treating it as a failure. ```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 +GET /api/v1/partner/users/{user_uuid}/fiat-accounts/{id}/ledger?page=N # every balance change +GET /api/v1/partner/users/{user_uuid}/fiat-accounts/{id}/payments?page=N # bank transfers in and out ``` The list carries `iban_last4` (or `account_number_last4` and `sort_code` for a @@ -275,9 +275,24 @@ read that failed is reported rather than shown as zero. 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. -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. +### Ledger and payments + +These are two views of the same money, not two copies of it. + +- **Ledger** lists every change to the balance: bank transfers, and also + conversions and internal transfers such as a deposit collected into your + master account. Use it to explain the balance. +- **Payments** lists bank transfers only, in both directions. `kind` is + `incoming` or `outgoing`, and `recipient`, `iban` and `bic` describe the other + side: the payer on an incoming payment, the payee on an outgoing one. Use it + to show who paid whom. + +They link through ids: the ledger entry a payment produced carries the payment's +`id` in `reference`. + +Both are paged with `?page=N`. `pagination` is present only when the bank +returns it; when it is absent, request the next page until one comes back empty. +Timestamps on both are Unix seconds. ## What happens when a deposit lands diff --git a/openapi/swagger.yaml b/openapi/swagger.yaml index 2522cda..cb48188 100644 --- a/openapi/swagger.yaml +++ b/openapi/swagger.yaml @@ -69,7 +69,7 @@ tags: ## 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 + 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 @@ -5072,13 +5072,19 @@ paths: get: tags: - User Management - summary: Transaction history + summary: Fiat account ledger operationId: getFiatAccountLedger description: | - One page of ledger entries for the account. + One page of the account's ledger: every entry that changed its balance. Bank transfers in + and out are here, and so are movements that are not bank transfers, such as conversions and + internal transfers (for example, a deposit collected into your master 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. + For the bank transfers themselves, with the payer or payee, use + `GET /users/{user_uuid}/fiat-accounts/{account_id}/payments`. A ledger entry caused by a + transfer carries that payment's `id` in `reference`. + + `pagination` is present only when the bank returns page metadata. When it is absent, the + number of pages is unknown: request the next page until one comes back empty. security: - ApiKeyAuth: [] parameters: @@ -5101,9 +5107,8 @@ paths: $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. + `CUSTOMER_NOT_FOUND`, or `FIAT_ACCOUNT_NOT_FOUND` when this customer holds no such + account. content: application/json: schema: @@ -5115,7 +5120,7 @@ paths: schema: $ref: '#/components/schemas/PartnerErrorResponse' '502': - description: The banking layer could not be read (`BANKING_ERROR` / `BANKING_UNAVAILABLE`) + description: The bank could not be read (`BANKING_ERROR`, `BANKING_UNAVAILABLE`) content: application/json: schema: @@ -5125,11 +5130,20 @@ paths: get: tags: - User Management - summary: Payment records + summary: Fiat account payments operationId: getFiatAccountPayments description: | - One page of payment records for the account — the incoming payments that credited it. - Same pagination caveat as the ledger. + One page of bank transfers on the account, in both directions. `kind` says which: + `incoming` (someone paid into the account) or `outgoing` (money sent from it). + `recipient`, `iban` and `bic` describe the other side: the payer on an incoming payment, + the payee on an outgoing one. + + When a payment changes the balance, the ledger entry it produced + (`GET /users/{user_uuid}/fiat-accounts/{account_id}/ledger`) carries this payment's `id` in + `reference`. + + `pagination` works as on the ledger: when it is absent, request the next page until one + comes back empty. security: - ApiKeyAuth: [] parameters: @@ -5138,6 +5152,7 @@ paths: - name: page in: query required: false + description: 1-based page number. Omitted or unparseable means the first page. schema: type: integer minimum: 1 @@ -5150,15 +5165,20 @@ paths: $ref: '#/components/schemas/FiatPaymentsResponse' '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. + `CUSTOMER_NOT_FOUND`, or `FIAT_ACCOUNT_NOT_FOUND` when this customer holds no such + account. + 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 + description: The bank could not be read (`BANKING_ERROR`, `BANKING_UNAVAILABLE`) content: application/json: schema: @@ -7706,42 +7726,88 @@ components: FiatLedgerEntry: type: object + description: One change to the account's balance. properties: id: { type: string } - type: { type: string } - category: { type: string } + type: + type: string + description: | + What kind of operation produced the entry: a bank transfer, a conversion, an internal + transfer, a fee. The values come from the bank and are not a closed list, so show them + rather than branch on them. + category: + type: string + description: A finer label from the bank. Not a closed list either. 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 } + description: | + The id of the operation that produced the entry. For a bank transfer it is the `id` of + that payment in `GET /users/{user_uuid}/fiat-accounts/{account_id}/payments`. Several + entries can share one `reference` when an operation moves more than one amount. + note: + type: string + description: The description attached to the operation, when there is one. created_at: type: integer format: int64 description: Unix seconds. + example: 1757415862 FiatPayment: type: object + description: One bank transfer into or out of the account. properties: - id: { type: string } + id: + type: string + description: The payment id. Ledger entries this payment produced carry it in `reference`. 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 } + kind: + type: string + enum: [incoming, outgoing] + description: '`incoming`: paid into the account. `outgoing`: sent from it.' + example: incoming + status: + type: string + enum: [initiated, pending, pending_approval, ready_to_release, sent, confirmed, completed, blocked, rejected, failed] + description: | + Where the payment stands. An incoming payment that credited the account reads + `confirmed`; an outgoing payment that left the account reads `sent`. + example: confirmed + recipient: + type: string + description: Name of the other side. The payer on an incoming payment, the payee on an outgoing one. + description: + type: string + description: The transfer's description (remittance text). + payment_rail: + type: string + description: The network the transfer went over, as the bank reports it. + iban: + type: string + description: The other side's IBAN, when the transfer carried one. + bic: + type: string + description: | + The other side's BIC. On a UK Faster Payments transfer this can hold the sort code + instead. + created_at: + type: integer + format: int64 + description: Unix seconds. + example: 1757415862 + updated_at: + type: integer + format: int64 + description: Unix seconds. 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. + Page metadata from the bank, present only when the bank returns it. When it is absent, + request the next page until one comes back empty. properties: current_page: { type: integer } per_page: { type: integer } From a1dffff97f620955e1f302539b567cd4a7491e50 Mon Sep 17 00:00:00 2001 From: hallstain Date: Fri, 18 Sep 2026 15:19:35 +0300 Subject: [PATCH 3/7] Name every fiat account endpoint for what it reads, and answer for what the API returns Read against the handlers, endpoint by endpoint. The names did not say these were fiat account routes, several descriptions argued for the design rather than describing the answer, and the error list was missing codes a partner can actually receive. - Summaries name the resource: "What fiat accounts you can offer", "Fiat account holder identity", "Fiat account holder verification status", "Submit a fiat account holder's identity", "This customer's fiat accounts", "Issue a fiat account", "One fiat account, with balances". - Dropped the justifications ("A GET on purpose", "deliberately", "the one route outside the customer tree"). What the endpoint answers is the documentation; why it is shaped that way is not. - Errors: added the codes the handlers reach and the docs never listed, in a second table for the ones that are about us rather than the request (`CUSTOMER_LOOKUP_UNAVAILABLE`, `IDENTITY_UNAVAILABLE`, `KYC_UNAVAILABLE`, `PRICING_UNAVAILABLE`, `PROVISION_UNAVAILABLE`, `DB_NOT_CONFIGURED`, `LOAD_FAILED`, `CREATE_FAILED`, `PROVISION_FAILED`, `LINK_WRITE_FAILED`, `STATUS_WRITE_FAILED`), plus `NO_OPERATING_ACCOUNT` and `CUSTOMER_UNKNOWN`. - A repeat identity submission answers `already_linked: true`, which was returned but never documented. - The payments and ledger schemas follow account#631: RFC 3339 times, a closed list of ledger types, a signed ledger amount, and the counterparty's `account_number` / `sort_code` on Faster Payments beside `iban` / `bic`. Co-Authored-By: Claude Opus 5 (1M context) --- fiat-accounts.md | 96 +++++++++++++++--------- openapi/swagger.yaml | 173 ++++++++++++++++++++++--------------------- 2 files changed, 147 insertions(+), 122 deletions(-) diff --git a/fiat-accounts.md b/fiat-accounts.md index 1cfca6d..2f76782 100644 --- a/fiat-accounts.md +++ b/fiat-accounts.md @@ -19,18 +19,16 @@ first. 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 customer -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. +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 separate banking +record 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. +So 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, 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 @@ -39,7 +37,7 @@ Those remain available in the Unigox console. ## 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": { … } } } @@ -53,7 +51,7 @@ validation failure — the request fields at fault: "success": false, "error": { "code": "MISSING_FIELDS", - "message": "The banking layer needs a few details this customer's verification did not capture.", + "message": "The bank needs a few details this customer's verification did not capture.", "details": { "missing_fields": ["address", "birthdate"] } } } @@ -139,13 +137,13 @@ GET /api/v1/partner/users/{user_uuid}/identity 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. +Returns what our KYC already established about this customer, and `missing`: the +fields the bank still needs. `ready: true` means step 3 will be accepted with an +empty body. -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. +It creates nothing and calls no one, so it is safe to call while you decide +whether to offer the product to a given customer. The document number is never +returned, only its last four digits. ### 3. Submit the customer's identity @@ -176,6 +174,10 @@ digits are kept. A gap answers `400` with `error.code: "MISSING_FIELDS"` and the field list under `error.details.missing_fields`. +Calling this again for a customer who is already an account holder answers `200` +with their current record and `already_linked: true`. Nothing is sent to the +bank twice. + ### 4. Wait for approval ```http @@ -199,13 +201,11 @@ X-API-Key: ``` 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. +`draft → pending_review → approved | rejected`. Approval is usually immediate, +but it is not guaranteed to be, which is why step 3 does not answer it. -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. +A customer you have not submitted yet answers `404 ACCOUNT_HOLDER_NOT_FOUND`: +polling never creates a holder record. ### 5. Issue the account @@ -292,7 +292,6 @@ They link through ids: the ledger entry a payment produced carries the payment's Both are paged with `?page=N`. `pagination` is present only when the bank returns it; when it is absent, request the next page until one comes back empty. -Timestamps on both are Unix seconds. ## What happens when a deposit lands @@ -308,7 +307,7 @@ amount, the currency and the account it came from. Register webhooks with | --- | --- | --- | | `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. | +| `INVALID_POSTAL_CODE` | 400 | Longer than the bank 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. | @@ -327,13 +326,38 @@ amount, the currency and the account it came from. Register webhooks with | `CUSTOMER_NOT_VERIFIED` | 422 | The customer's KYC is not (or no longer) verified. | | `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. +| `CUSTOMER_UNKNOWN` | 422 | The customer has no account holder record behind them yet. | +| `NO_OPERATING_ACCOUNT` | 403 | Your partner has no banking account behind it. Ask Unigox; no request on this API will work until it does. | +| `RECORD_FAILED` | 500 | The account was opened but could not be recorded. **Do not retry**, contact Unigox to reconcile. | +| `BANKING_ERROR` | 502 | The bank refused or failed the request. | +| `BANKING_UNAVAILABLE` | 502 / 503 | The bank could not be reached. | + +`BANKING_ERROR` and `BANKING_UNAVAILABLE` mean the request reached the bank 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. + +### Failures that are not about your request + +These say a piece of Unigox could not answer just now. Nothing was changed, and +every one of them is safe to retry. + +| `error.code` | Status | Which step | +| --- | --- | --- | +| `ENTITLEMENT_UNAVAILABLE` | 503 | Your entitlements could not be read. | +| `CUSTOMER_LOOKUP_UNAVAILABLE` | 503 | The customer could not be looked up. | +| `IDENTITY_UNAVAILABLE` | 503 | The verified identity could not be read. | +| `KYC_UNAVAILABLE` | 503 | The KYC verdict could not be read. | +| `HOLDER_UNAVAILABLE` | 503 | The customer could not be registered as a holder. | +| `PRICING_UNAVAILABLE` | 503 | Pricing for the currency could not be read. | +| `PROVISION_UNAVAILABLE` | 503 | Issuance could not be started. | +| `DB_NOT_CONFIGURED` | 503 | A store this route needs is not configured in this environment. | +| `LOAD_FAILED` | 500 | A record could not be read. | +| `CREATE_FAILED` | 500 | A record could not be written. | +| `PROVISION_FAILED` | 500 | Issuance failed before the account was opened. | +| `LINK_WRITE_FAILED` | 500 | The identity reached the bank but the link could not be stored. | +| `STATUS_WRITE_FAILED` | 500 | The bank answered but the status could not be stored. | + +The last two mean the bank has your submission even though we could not record +its answer. Poll `GET /users/{user_uuid}/identification` rather than submitting +again. diff --git a/openapi/swagger.yaml b/openapi/swagger.yaml index cb48188..b347a00 100644 --- a/openapi/swagger.yaml +++ b/openapi/swagger.yaml @@ -69,10 +69,11 @@ tags: ## Fiat accounts Issue dedicated fiat accounts (IBANs) for your customers and read their balances, ledger and - 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 + payments. Every account is reached through the customer that holds it, so an account id on + its own will not read an account. + + An optional product 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. @@ -4726,19 +4727,16 @@ paths: get: tags: - User Management - summary: What you can offer + 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 use fiat accounts right now, which currencies an account may be denominated + in, and which jurisdictions each currency can be issued from. Call it before any customer is + in scope; it is the only 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 put customers through verification but not open accounts for them. security: - ApiKeyAuth: [] responses: @@ -4759,17 +4757,17 @@ paths: get: tags: - User Management - summary: What identity we hold, and what is still needed + summary: Fiat account holder identity, and what is still missing 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. + What Unigox KYC already established about this customer, and `missing`: the fields the bank + still needs before an account can be opened. `ready: true` means the next step accepts 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. + This creates nothing and stays available when the product is switched off, so you can always + see why a customer cannot be issued an account. - The document number is never returned — only its last four digits. + The document number is never returned, only its last four digits. security: - ApiKeyAuth: [] parameters: @@ -4792,14 +4790,14 @@ paths: get: tags: - User Management - summary: Verification status of the account holder + summary: Fiat account holder verification status operationId: getFiatIdentification description: | - The holder's current verification state. Poll this until `can_open_accounts` is `true`. + Where the holder's verification stands. Poll this until `can_open_accounts` is `true`; + approval is usually immediate, but it is not guaranteed to be. - 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. + Once the identity has reached the bank this re-reads their verdict, so the status can change + between two calls without you sending anything. security: - ApiKeyAuth: [] parameters: @@ -4813,15 +4811,14 @@ paths: $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. + `CUSTOMER_NOT_FOUND`, or `ACCOUNT_HOLDER_NOT_FOUND` when this customer has not been + submitted for verification yet. Polling never creates a holder record. content: application/json: schema: $ref: '#/components/schemas/PartnerErrorResponse' '502': - description: The banking layer could not be read + description: The bank could not be read (`BANKING_ERROR`, `BANKING_UNAVAILABLE`) content: application/json: schema: @@ -4829,22 +4826,22 @@ paths: post: tags: - User Management - summary: Submit the customer's identity + summary: Submit a fiat account holder's identity operationId: submitFiatIdentification description: | - Submits the customer's identity to the banking layer, registering them as an account holder - if this is the first call. + Sends the customer's identity to the bank, registering them as an account holder if this is + the first call. Do this once per customer, before opening their first account. - **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. + **Every body field is optional.** What Unigox verified at KYC is preferred over what you + send, so send only what `GET /users/{user_uuid}/identity` reported as `missing`. The name, + the country of residence and the document come 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. - Idempotent: a customer already submitted answers `200` with their current holder record and - makes no second submission. + Idempotent: a customer already submitted answers `200` with their current holder record, + `already_linked: true`, and no second submission. security: - ApiKeyAuth: [] parameters: @@ -4857,7 +4854,7 @@ paths: $ref: '#/components/schemas/FiatIdentificationRequest' responses: '200': - description: Submitted (or already submitted) + description: 'Submitted, or already submitted, which adds `already_linked: true`' content: application/json: schema: @@ -4899,7 +4896,7 @@ paths: schema: $ref: '#/components/schemas/PartnerErrorResponse' '502': - description: The banking layer refused or failed the submission + description: The bank refused or failed the submission (`BANKING_ERROR`, `BANKING_UNAVAILABLE`) content: application/json: schema: @@ -4909,14 +4906,13 @@ paths: get: tags: - User Management - summary: This customer's accounts + summary: This customer's fiat 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. + Every fiat account issued to this customer. A customer with none yet answers an empty list. - Only the last four digits of the identifier travel here — fetch a single account for the - full details a payer needs. + Only the last four digits of the identifier travel here. Fetch a single account for the full + details a payer needs. security: - ApiKeyAuth: [] parameters: @@ -4937,16 +4933,16 @@ paths: post: tags: - User Management - summary: Issue an account + 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 + needed. The holder must already be approved, see `GET /users/{user_uuid}/identification`. **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 + 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`. Two requests racing for the same account answer `409 PROVISIONING_IN_PROGRESS`. Retry once @@ -5029,17 +5025,14 @@ paths: get: tags: - User Management - summary: One account, with balances + summary: One fiat account, with balances 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. - - 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. + The full details a payer needs, including the complete IBAN or account number, plus the + `holder` the account belongs to and the live `balances`. - `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: @@ -5054,9 +5047,8 @@ paths: $ref: '#/components/schemas/FiatAccountDetailResponse' '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. + `CUSTOMER_NOT_FOUND`, or `FIAT_ACCOUNT_NOT_FOUND` when this customer holds no such + account. An account held by a different customer answers the same way. content: application/json: schema: @@ -5135,8 +5127,10 @@ paths: description: | One page of bank transfers on the account, in both directions. `kind` says which: `incoming` (someone paid into the account) or `outgoing` (money sent from it). - `recipient`, `iban` and `bic` describe the other side: the payer on an incoming payment, - the payee on an outgoing one. + + The other side of the transfer is `recipient` — the payer on an incoming payment, the payee + on an outgoing one — plus the identifiers the transfer carried: `iban` and `bic` on SEPA, + `account_number` and `sort_code` on UK Faster Payments. When a payment changes the balance, the ledger entry it produced (`GET /users/{user_uuid}/fiat-accounts/{account_id}/ledger`) carries this payment's `id` in @@ -7731,14 +7725,17 @@ components: id: { type: string } type: type: string + enum: [bank_transfer, conversion, internal_transfer, fee, crypto_transfer, other] description: | - What kind of operation produced the entry: a bank transfer, a conversion, an internal - transfer, a fee. The values come from the bank and are not a closed list, so show them - rather than branch on them. - category: - type: string - description: A finer label from the bank. Not a closed list either. - amount: { type: string, example: "500.00" } + What produced the entry. `bank_transfer` is a payment into or out of the account, + `internal_transfer` a movement between Unigox-held accounts (a deposit collected into + your master account, for one), `conversion` a currency conversion, `fee` a charge. + `other` is anything else the bank books; treat it as descriptive. + example: bank_transfer + amount: + type: string + description: Signed. A debit is negative. + example: "-500.00" currency: { type: string, example: EUR } reference: type: string @@ -7749,11 +7746,7 @@ components: note: type: string description: The description attached to the operation, when there is one. - created_at: - type: integer - format: int64 - description: Unix seconds. - example: 1757415862 + created_at: { type: string, format: date-time } FiatPayment: type: object @@ -7787,21 +7780,24 @@ components: description: The network the transfer went over, as the bank reports it. iban: type: string - description: The other side's IBAN, when the transfer carried one. + description: The other side's IBAN, on a transfer addressed by one. + example: "NL91ABNA0417164300" bic: + type: string + description: The other side's BIC, on a transfer addressed by one. + example: ABNANL2A + account_number: type: string description: | - The other side's BIC. On a UK Faster Payments transfer this can hold the sort code - instead. - created_at: - type: integer - format: int64 - description: Unix seconds. - example: 1757415862 - updated_at: - type: integer - format: int64 - description: Unix seconds. + The other side's local account number, on a transfer that carries no IBAN (a UK Faster + Payments one). Present instead of `iban`. + example: "25976129" + sort_code: + type: string + description: The other side's sort code, present instead of `bic` on the same transfers. + example: "23-08-01" + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } FiatPagination: type: object @@ -7908,6 +7904,11 @@ components: properties: holder: $ref: '#/components/schemas/FiatAccountHolder' + already_linked: + type: boolean + description: | + Present, and `true`, when a submission found this customer already registered as an + account holder. Nothing was sent to the bank a second time. FiatLedgerResponse: type: object From 3064ef2cf3d9a26c19683e8f2ba141053c987409 Mon Sep 17 00:00:00 2001 From: hallstain Date: Fri, 18 Sep 2026 16:28:48 +0300 Subject: [PATCH 4/7] Document one transactions list, in place of the ledger and the payments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two routes were two views of the same money: the ledger accounted for the balance but never said who paid, the payments list said who paid but knew nothing of the conversions, fees and internal transfers around it. Reading an account meant fetching both and joining them by `reference`. `GET …/fiat-accounts/{account_id}/transactions` replaces both. One row is one movement: `type` says what moved it, `direction` and a signed `amount` say which way, and a bank transfer carries the other side of itself under `counterparty` with the identifiers that transfer was addressed by. Co-Authored-By: Claude Opus 5 (1M context) --- fiat-accounts.md | 35 ++++---- openapi/swagger.yaml | 199 ++++++++++++++----------------------------- 2 files changed, 80 insertions(+), 154 deletions(-) diff --git a/fiat-accounts.md b/fiat-accounts.md index 2f76782..316a13e 100644 --- a/fiat-accounts.md +++ b/fiat-accounts.md @@ -5,8 +5,7 @@ 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 ledger and the bank transfers in and out -of the account. +money, and you can read the balance and every transaction on the account. **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 @@ -97,7 +96,7 @@ account. 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. +6. Read balances and transactions as deposits arrive. ### 1. See what you can offer @@ -262,8 +261,7 @@ settles rather than treating it as a failure. ```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 # every balance change -GET /api/v1/partner/users/{user_uuid}/fiat-accounts/{id}/payments?page=N # bank transfers in and out +GET /api/v1/partner/users/{user_uuid}/fiat-accounts/{id}/transactions?page=N # everything that moved the balance ``` The list carries `iban_last4` (or `account_number_last4` and `sort_code` for a @@ -275,23 +273,24 @@ read that failed is reported rather than shown as zero. 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. -### Ledger and payments +### Transactions -These are two views of the same money, not two copies of it. +One row is one movement of the balance, newest first. -- **Ledger** lists every change to the balance: bank transfers, and also - conversions and internal transfers such as a deposit collected into your - master account. Use it to explain the balance. -- **Payments** lists bank transfers only, in both directions. `kind` is - `incoming` or `outgoing`, and `recipient`, `iban` and `bic` describe the other - side: the payer on an incoming payment, the payee on an outgoing one. Use it - to show who paid whom. +- **Bank transfers in and out** carry the other side of the transfer under + `counterparty`: the payer on money in, the payee on money out, with the + identifiers the transfer was addressed by. `status` is where that transfer + stands, and `payment_rail` is how it travelled. +- **Everything else that moved the balance** is here too: conversions, fees, and + internal transfers such as a deposit collected into your master account. Those + rows carry no counterparty, because there is none. -They link through ids: the ledger entry a payment produced carries the payment's -`id` in `reference`. +`type` says which of the two a row is (`bank_transfer`, `conversion`, +`internal_transfer`, `fee`, `crypto_transfer`, `other`), `direction` is `in` or +`out`, and `amount` is signed — a debit is negative. -Both are paged with `?page=N`. `pagination` is present only when the bank -returns it; when it is absent, request the next page until one comes back empty. +Paged with `?page=N`. `pagination` is present only when the bank returns it; +when it is absent, request the next page until one comes back empty. ## What happens when a deposit lands diff --git a/openapi/swagger.yaml b/openapi/swagger.yaml index b347a00..6215ee3 100644 --- a/openapi/swagger.yaml +++ b/openapi/swagger.yaml @@ -5060,20 +5060,20 @@ paths: schema: $ref: '#/components/schemas/PartnerErrorResponse' - /api/v1/partner/users/{user_uuid}/fiat-accounts/{account_id}/ledger: + /api/v1/partner/users/{user_uuid}/fiat-accounts/{account_id}/transactions: get: tags: - User Management - summary: Fiat account ledger - operationId: getFiatAccountLedger + summary: Fiat account transactions + operationId: getFiatAccountTransactions description: | - One page of the account's ledger: every entry that changed its balance. Bank transfers in - and out are here, and so are movements that are not bank transfers, such as conversions and - internal transfers (for example, a deposit collected into your master account). + One page of everything that moved the account's balance, newest first. Bank transfers in and + out are here, and so are the movements that are not transfers: conversions, fees, and + internal transfers such as a deposit collected into your master account. - For the bank transfers themselves, with the payer or payee, use - `GET /users/{user_uuid}/fiat-accounts/{account_id}/payments`. A ledger entry caused by a - transfer carries that payment's `id` in `reference`. + A transfer carries the other side of it on the same row, under `counterparty`: the payer on + money in, the payee on money out, with the identifiers that transfer was addressed by. There + is no second list to reconcile against, and one row is one movement. `pagination` is present only when the bank returns page metadata. When it is absent, the number of pages is unknown: request the next page until one comes back empty. @@ -5092,75 +5092,15 @@ paths: example: 2 responses: '200': - description: Ledger page + description: Transactions page content: application/json: schema: - $ref: '#/components/schemas/FiatLedgerResponse' + $ref: '#/components/schemas/FiatTransactionsResponse' '404': description: | `CUSTOMER_NOT_FOUND`, or `FIAT_ACCOUNT_NOT_FOUND` when this customer holds no such - account. - 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 bank could not be read (`BANKING_ERROR`, `BANKING_UNAVAILABLE`) - content: - application/json: - schema: - $ref: '#/components/schemas/PartnerErrorResponse' - - /api/v1/partner/users/{user_uuid}/fiat-accounts/{account_id}/payments: - get: - tags: - - User Management - summary: Fiat account payments - operationId: getFiatAccountPayments - description: | - One page of bank transfers on the account, in both directions. `kind` says which: - `incoming` (someone paid into the account) or `outgoing` (money sent from it). - - The other side of the transfer is `recipient` — the payer on an incoming payment, the payee - on an outgoing one — plus the identifiers the transfer carried: `iban` and `bic` on SEPA, - `account_number` and `sort_code` on UK Faster Payments. - - When a payment changes the balance, the ledger entry it produced - (`GET /users/{user_uuid}/fiat-accounts/{account_id}/ledger`) carries this payment's `id` in - `reference`. - - `pagination` works as on the ledger: when it is absent, request the next page until one - comes back empty. - 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 - responses: - '200': - description: Payments page - content: - application/json: - schema: - $ref: '#/components/schemas/FiatPaymentsResponse' - '404': - description: | - `CUSTOMER_NOT_FOUND`, or `FIAT_ACCOUNT_NOT_FOUND` when this customer holds no such - account. + account. An account held by a different customer answers the same way. content: application/json: schema: @@ -7718,86 +7658,88 @@ components: pending: { type: string, example: "0.00" } total: { type: string, example: "1250.00" } - FiatLedgerEntry: + FiatTransaction: type: object - description: One change to the account's balance. + description: | + One movement of the account's balance. A bank transfer also carries the other side of it; + a conversion, a fee or an internal transfer has no counterparty to carry. properties: - id: { type: string } + id: + type: string + description: The transaction id. Opaque, and stable across pages. + example: "5f1b2c9a" type: type: string enum: [bank_transfer, conversion, internal_transfer, fee, crypto_transfer, other] description: | - What produced the entry. `bank_transfer` is a payment into or out of the account, + What moved the money. `bank_transfer` is a payment into or out of the account, `internal_transfer` a movement between Unigox-held accounts (a deposit collected into your master account, for one), `conversion` a currency conversion, `fee` a charge. `other` is anything else the bank books; treat it as descriptive. example: bank_transfer + direction: + type: string + enum: [in, out] + description: Which way the money went, from the sign of the amount. + example: in amount: type: string description: Signed. A debit is negative. - example: "-500.00" - currency: { type: string, example: EUR } - reference: - type: string - description: | - The id of the operation that produced the entry. For a bank transfer it is the `id` of - that payment in `GET /users/{user_uuid}/fiat-accounts/{account_id}/payments`. Several - entries can share one `reference` when an operation moves more than one amount. - note: - type: string - description: The description attached to the operation, when there is one. - created_at: { type: string, format: date-time } - - FiatPayment: - type: object - description: One bank transfer into or out of the account. - properties: - id: - type: string - description: The payment id. Ledger entries this payment produced carry it in `reference`. - amount: { type: string, example: "500.00" } - currency: { type: string, example: EUR } - kind: + example: "500.00" + currency: type: string - enum: [incoming, outgoing] - description: '`incoming`: paid into the account. `outgoing`: sent from it.' - example: incoming + example: EUR status: type: string - enum: [initiated, pending, pending_approval, ready_to_release, sent, confirmed, completed, blocked, rejected, failed] description: | - Where the payment stands. An incoming payment that credited the account reads - `confirmed`; an outgoing payment that left the account reads `sent`. + On a bank transfer, where that transfer stands: `initiated`, `pending`, + `pending_approval`, `ready_to_release`, `sent`, `confirmed`, `completed`, `blocked`, + `rejected` or `failed`. Money in that landed reads `confirmed`; money out that left + reads `sent`. Absent on a movement with no separate lifecycle. example: confirmed - recipient: + counterparty: + $ref: '#/components/schemas/FiatCounterparty' + payment_rail: type: string - description: Name of the other side. The payer on an incoming payment, the payee on an outgoing one. + description: On a bank transfer, the network it went over, as the bank reports it. description: type: string - description: The transfer's description (remittance text). - payment_rail: + description: What the movement was sent with, when it carried anything. + reference: type: string - description: The network the transfer went over, as the bank reports it. + description: | + The id of the operation behind the transaction, for reconciling against a statement. + created_at: + type: string + format: date-time + + FiatCounterparty: + type: object + description: | + The other side of a bank transfer: the payer on money in, the payee on money out. Present on + `bank_transfer` transactions, and only for as much as the transfer carried. + properties: + name: + type: string + example: ACME LTD iban: type: string - description: The other side's IBAN, on a transfer addressed by one. - example: "NL91ABNA0417164300" + description: On a transfer addressed by an IBAN. + example: "NL02ABNA0123456789" bic: type: string - description: The other side's BIC, on a transfer addressed by one. + description: On a transfer addressed by a BIC. example: ABNANL2A account_number: type: string description: | - The other side's local account number, on a transfer that carries no IBAN (a UK Faster - Payments one). Present instead of `iban`. + On a transfer that carries no IBAN (a UK Faster Payments one). Present instead of + `iban`. example: "25976129" sort_code: type: string - description: The other side's sort code, present instead of `bic` on the same transfers. + description: Present instead of `bic` on the same transfers. example: "23-08-01" - created_at: { type: string, format: date-time } - updated_at: { type: string, format: date-time } FiatPagination: type: object @@ -7910,22 +7852,7 @@ components: Present, and `true`, when a submission found this customer already registered as an account holder. Nothing was sent to the bank a second time. - FiatLedgerResponse: - type: object - required: [success, data] - properties: - success: { type: boolean, example: true } - data: - type: object - properties: - entries: - type: array - items: - $ref: '#/components/schemas/FiatLedgerEntry' - pagination: - $ref: '#/components/schemas/FiatPagination' - - FiatPaymentsResponse: + FiatTransactionsResponse: type: object required: [success, data] properties: @@ -7933,10 +7860,10 @@ components: data: type: object properties: - payments: + transactions: type: array items: - $ref: '#/components/schemas/FiatPayment' + $ref: '#/components/schemas/FiatTransaction' pagination: $ref: '#/components/schemas/FiatPagination' From ec25fc41d6fbbb8ddae68d0d56764bf1e727eaae Mon Sep 17 00:00:00 2001 From: hallstain Date: Fri, 18 Sep 2026 19:47:40 +0300 Subject: [PATCH 5/7] Publish the rebuilt fiat account surface (UHT-520) One resource, addressed by `fiat_account_id`, opened with `POST /fiat-accounts` and `{user_uuid, currency}`. The identity and identification endpoints are gone from the docs because they are gone from the API: they were the banking vendor's KYC push, and a partner has one identity for a person already. - Fiat Accounts is its own reference section, a sibling of User Management rather than a heading inside it. - The guide follows the flow a partner actually has: config, readiness on the customer, open, wait for `active`, show the pay-in details, read the credits. - Two webhooks documented: `fiat_account.updated` and `fiat_account.deposit.received`. `retail.settlement.completed` is not a partner event and is no longer mentioned as one. - On-ramp funding from the customer's own account is `own_account`, and the order names the `fiat_account_id` it is funded from. Co-Authored-By: Claude Opus 5 (1M context) --- changelog.md | 14 + fiat-accounts.md | 439 ++++++++++----------- openapi/swagger.yaml | 882 +++++++++++++------------------------------ 3 files changed, 475 insertions(+), 860 deletions(-) diff --git a/changelog.md b/changelog.md index c2b615a..586331d 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,20 @@ Notable changes to the Unigox partner API, newest first. +## 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-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 316a13e..4938926 100644 --- a/fiat-accounts.md +++ b/fiat-accounts.md @@ -1,102 +1,84 @@ # Fiat accounts -Give your customers a bank account of their own — a dedicated IBAN they can be +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 and every transaction on the account. +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 `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. +**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.** 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. +**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 +## 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 separate banking -record to register and keep in step. +`POST /api/v1/partner/users` and put through KYC. There is no second identity to +register. -So 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, and the identity the bank opens the account on is -the one Unigox verified, not one you retype into a request body. +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. -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. +``` +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 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 bank 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 and transactions 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 @@ -109,254 +91,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 bank still needs. `ready: true` means step 3 will be accepted with an -empty body. +The customer carries one more object: -It creates nothing and calls no one, so it is safe to call while you decide -whether to offer the product to a given customer. The document number is never -returned, only its last four digits. +```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. + +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`. - -Calling this again for a customer who is already an account holder answers `200` -with their current record and `already_linked: true`. Nothing is sent to the -bank twice. - -### 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 it is not guaranteed to be, which is why step 3 does not answer it. +**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. + +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. + +- 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. | -A customer you have not submitted yet answers `404 ACCOUNT_HOLDER_NOT_FOUND`: -polling never creates a holder record. +`fiat_account.updated` fires when this changes, and +`GET /fiat-accounts/{fiat_account_id}` answers the same thing when you poll. -### 5. Issue the account +### 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` | -`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. +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. + +### 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`. - -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. +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. -### 6. Read the account - -```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}/transactions?page=N # everything that moved the balance -``` +## Webhooks -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. +Two events, in the same envelope and with the same signature as +`order.status.changed`. -**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. - -### Transactions +| `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) | -One row is one movement of the balance, newest first. +Register your endpoint with `POST /api/v1/partner/webhooks` as usual; there is +no per-event subscription. -- **Bank transfers in and out** carry the other side of the transfer under - `counterparty`: the payer on money in, the payee on money out, with the - identifiers the transfer was addressed by. `status` is where that transfer - stands, and `payment_rail` is how it travelled. -- **Everything else that moved the balance** is here too: conversions, fees, and - internal transfers such as a deposit collected into your master account. Those - rows carry no counterparty, because there is none. +## Paying for an on-ramp from the account -`type` says which of the two a row is (`bank_transfer`, `conversion`, -`internal_transfer`, `fee`, `crypto_transfer`, `other`), `direction` is `in` or -`out`, and `amount` is signed — a debit is negative. +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`. -Paged with `?page=N`. `pagination` is present only when the bank returns it; -when it is absent, request the next page until one comes back empty. +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 bank 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. | -| `CUSTOMER_UNKNOWN` | 422 | The customer has no account holder record behind them yet. | -| `NO_OPERATING_ACCOUNT` | 403 | Your partner has no banking account behind it. Ask Unigox; no request on this API will work until it does. | -| `RECORD_FAILED` | 500 | The account was opened but could not be recorded. **Do not retry**, contact Unigox to reconcile. | | `BANKING_ERROR` | 502 | The bank refused or failed the request. | -| `BANKING_UNAVAILABLE` | 502 / 503 | The bank could not be reached. | +| `BANKING_UNAVAILABLE` | 503 | The bank could not be reached. Nothing was done. | -`BANKING_ERROR` and `BANKING_UNAVAILABLE` mean the request reached the bank and -did not complete. Both are safe to retry: issuance is idempotent per (customer, +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. - -### Failures that are not about your request - -These say a piece of Unigox could not answer just now. Nothing was changed, and -every one of them is safe to retry. - -| `error.code` | Status | Which step | -| --- | --- | --- | -| `ENTITLEMENT_UNAVAILABLE` | 503 | Your entitlements could not be read. | -| `CUSTOMER_LOOKUP_UNAVAILABLE` | 503 | The customer could not be looked up. | -| `IDENTITY_UNAVAILABLE` | 503 | The verified identity could not be read. | -| `KYC_UNAVAILABLE` | 503 | The KYC verdict could not be read. | -| `HOLDER_UNAVAILABLE` | 503 | The customer could not be registered as a holder. | -| `PRICING_UNAVAILABLE` | 503 | Pricing for the currency could not be read. | -| `PROVISION_UNAVAILABLE` | 503 | Issuance could not be started. | -| `DB_NOT_CONFIGURED` | 503 | A store this route needs is not configured in this environment. | -| `LOAD_FAILED` | 500 | A record could not be read. | -| `CREATE_FAILED` | 500 | A record could not be written. | -| `PROVISION_FAILED` | 500 | Issuance failed before the account was opened. | -| `LINK_WRITE_FAILED` | 500 | The identity reached the bank but the link could not be stored. | -| `STATUS_WRITE_FAILED` | 500 | The bank answered but the status could not be stored. | - -The last two mean the bank has your submission even though we could not record -its answer. Poll `GET /users/{user_uuid}/identification` rather than submitting -again. diff --git a/openapi/swagger.yaml b/openapi/swagger.yaml index 6215ee3..41311d4 100644 --- a/openapi/swagger.yaml +++ b/openapi/swagger.yaml @@ -66,17 +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 - payments. Every account is reached through the customer that holds it, so an account id on - its own will not read an account. - - An optional product 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 @@ -90,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 @@ -100,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. @@ -4726,17 +4750,17 @@ paths: /api/v1/partner/fiat-accounts/config: get: tags: - - User Management + - Fiat Accounts summary: What fiat accounts you can offer operationId: getFiatAccountsConfig description: | - Whether you may use fiat accounts right now, which currencies an account may be denominated - in, and which jurisdictions each currency can be issued from. Call it before any customer is - in scope; it is the only fiat account route that names no customer. + 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. - Entitlement is never an error here. When the product is not active it answers `200` with + 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 put customers through verification but not open accounts for them. + means you may read accounts but not open them. security: - ApiKeyAuth: [] responses: @@ -4745,7 +4769,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/FiatConfigResponse' + $ref: '#/components/schemas/FiatAccountConfigResponse' '401': description: Missing or invalid credential content: @@ -4753,177 +4777,36 @@ paths: schema: $ref: '#/components/schemas/PartnerErrorResponse' - /api/v1/partner/users/{user_uuid}/identity: - get: - tags: - - User Management - summary: Fiat account holder identity, and what is still missing - operationId: getFiatCustomerIdentity - description: | - What Unigox KYC already established about this customer, and `missing`: the fields the bank - still needs before an account can be opened. `ready: true` means the next step accepts an - empty body. - - This creates nothing and stays available when the product is switched off, so you 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: - get: - tags: - - User Management - summary: Fiat account holder verification status - operationId: getFiatIdentification - description: | - Where the holder's verification stands. Poll this until `can_open_accounts` is `true`; - approval is usually immediate, but it is not guaranteed to be. - - Once the identity has reached the bank this re-reads their verdict, so the status can change - between two calls without you sending anything. - security: - - ApiKeyAuth: [] - parameters: - - $ref: '#/components/parameters/FiatUserUuid' - responses: - '200': - description: Holder status - content: - application/json: - schema: - $ref: '#/components/schemas/FiatHolderResponse' - '404': - description: | - `CUSTOMER_NOT_FOUND`, or `ACCOUNT_HOLDER_NOT_FOUND` when this customer has not been - submitted for verification yet. Polling never creates a holder record. - content: - application/json: - schema: - $ref: '#/components/schemas/PartnerErrorResponse' - '502': - description: The bank could not be read (`BANKING_ERROR`, `BANKING_UNAVAILABLE`) - content: - application/json: - schema: - $ref: '#/components/schemas/PartnerErrorResponse' - post: - tags: - - User Management - summary: Submit a fiat account holder's identity - operationId: submitFiatIdentification - description: | - Sends the customer's identity to the bank, registering them as an account holder if this is - the first call. Do this once per customer, before opening their first account. - - **Every body field is optional.** What Unigox verified at KYC is preferred over what you - send, so send only what `GET /users/{user_uuid}/identity` reported as `missing`. The name, - the country of residence and the document come 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. - - Idempotent: a customer already submitted answers `200` with their current holder record, - `already_linked: true`, and no second submission. - 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, which adds `already_linked: true`' - 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 bank refused or failed the submission (`BANKING_ERROR`, `BANKING_UNAVAILABLE`) - content: - application/json: - schema: - $ref: '#/components/schemas/PartnerErrorResponse' - - /api/v1/partner/users/{user_uuid}/fiat-accounts: + /api/v1/partner/fiat-accounts: get: tags: - - User Management - summary: This customer's fiat accounts - operationId: listFiatAccountsForUser + - Fiat Accounts + summary: List a customer's fiat accounts + operationId: listFiatAccounts description: | - Every fiat account issued to this customer. A customer with none yet answers an empty list. + Every fiat account issued to one of your customers. A customer with none yet answers an + empty list. - Only the last four digits of the identifier travel here. Fetch a single account for the full - details a payer needs. + 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' + - 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: @@ -4932,52 +4815,50 @@ paths: $ref: '#/components/schemas/PartnerErrorResponse' post: tags: - - User Management + - 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: @@ -4989,54 +4870,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`). - content: - application/json: - schema: - $ref: '#/components/schemas/PartnerErrorResponse' - '502': - description: The banking layer refused to open the account + 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' - '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 fiat account, with balances + - Fiat Accounts + summary: One fiat account operationId: getFiatAccount description: | - The full details a payer needs, including the complete IBAN or account number, plus the - `holder` the account 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. + + 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. 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': @@ -5044,43 +4917,31 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/FiatAccountDetailResponse' + $ref: '#/components/schemas/FiatAccountResponse' '404': - description: | - `CUSTOMER_NOT_FOUND`, or `FIAT_ACCOUNT_NOT_FOUND` when this customer holds no such - account. An account held by a different customer answers the same way. - 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}/transactions: + /api/v1/partner/fiat-accounts/{fiat_account_id}/transactions: get: tags: - - User Management - summary: Fiat account transactions + - Fiat Accounts + summary: Money that moved on a fiat account operationId: getFiatAccountTransactions description: | - One page of everything that moved the account's balance, newest first. Bank transfers in and - out are here, and so are the movements that are not transfers: conversions, fees, and - internal transfers such as a deposit collected into your master 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. - A transfer carries the other side of it on the same row, under `counterparty`: the payer on - money in, the payee on money out, with the identifiers that transfer was addressed by. There - is no second list to reconcile against, and one row is one movement. + 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. - `pagination` is present only when the bank returns page metadata. When it is absent, the - number of pages is unknown: request the next page until one comes back empty. + 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 @@ -5089,7 +4950,7 @@ paths: schema: type: integer minimum: 1 - example: 2 + example: 1 responses: '200': description: Transactions page @@ -5098,21 +4959,13 @@ paths: schema: $ref: '#/components/schemas/FiatTransactionsResponse' '404': - description: | - `CUSTOMER_NOT_FOUND`, or `FIAT_ACCOUNT_NOT_FOUND` when this customer holds no such - account. An account held by a different customer answers the same way. - 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' - '502': - description: The bank could not be read (`BANKING_ERROR`, `BANKING_UNAVAILABLE`) + '503': + description: The bank could not be read (`BANKING_UNAVAILABLE`) content: application/json: schema: @@ -7215,7 +7068,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 @@ -7501,156 +7354,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 } @@ -7658,199 +7515,62 @@ components: pending: { type: string, example: "0.00" } total: { type: string, example: "1250.00" } + FiatAccountResponse: + type: object + required: [success, data] + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/FiatAccount' + + FiatAccountListResponse: + type: object + required: [success, data] + properties: + success: { type: boolean, example: true } + data: + type: object + properties: + fiat_accounts: + type: array + items: + $ref: '#/components/schemas/FiatAccount' + FiatTransaction: type: object - description: | - One movement of the account's balance. A bank transfer also carries the other side of it; - a conversion, a fee or an internal transfer has no counterparty to carry. + description: One movement of money on a fiat account. properties: - id: + transaction_id: type: string - description: The transaction id. Opaque, and stable across pages. - example: "5f1b2c9a" + description: The transaction's id, and the id a deposit webhook names it by. + example: "5f1b2c9a-1d44-4f0e-9c1a-2b5f0d7e9a31" type: type: string - enum: [bank_transfer, conversion, internal_transfer, fee, crypto_transfer, other] + enum: [credit] description: | - What moved the money. `bank_transfer` is a payment into or out of the account, - `internal_transfer` a movement between Unigox-held accounts (a deposit collected into - your master account, for one), `conversion` a currency conversion, `fee` a charge. - `other` is anything else the bank books; treat it as descriptive. - example: bank_transfer - direction: - type: string - enum: [in, out] - description: Which way the money went, from the sign of the amount. - example: in + `credit` — money arrived on the account. v1 is receive-only, so this is the only value. + example: credit amount: type: string - description: Signed. A debit is negative. example: "500.00" currency: type: string example: EUR - status: + order_id: type: string + nullable: true description: | - On a bank transfer, where that transfer stands: `initiated`, `pending`, - `pending_approval`, `ready_to_release`, `sent`, `confirmed`, `completed`, `blocked`, - `rejected` or `failed`. Money in that landed reads `confirmed`; money out that left - reads `sent`. Absent on a movement with no separate lifecycle. - example: confirmed - counterparty: - $ref: '#/components/schemas/FiatCounterparty' - payment_rail: - type: string - description: On a bank transfer, the network it went over, as the bank reports it. + 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 movement was sent with, when it carried anything. - reference: - type: string - description: | - The id of the operation behind the transaction, for reconciling against a statement. + description: What the payer sent the transfer with, when it carried anything. + example: "Invoice 2026-114" created_at: type: string format: date-time - - FiatCounterparty: - type: object - description: | - The other side of a bank transfer: the payer on money in, the payee on money out. Present on - `bank_transfer` transactions, and only for as much as the transfer carried. - properties: - name: - type: string - example: ACME LTD - iban: - type: string - description: On a transfer addressed by an IBAN. - example: "NL02ABNA0123456789" - bic: - type: string - description: On a transfer addressed by a BIC. - example: ABNANL2A - account_number: - type: string - description: | - On a transfer that carries no IBAN (a UK Faster Payments one). Present instead of - `iban`. - example: "25976129" - sort_code: - type: string - description: Present instead of `bic` on the same transfers. - example: "23-08-01" - - FiatPagination: - type: object - description: | - Page metadata from the bank, present only when the bank returns it. When it is absent, - request the next page until one comes back empty. - 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: - type: object - required: [success, data] - properties: - success: { type: boolean, example: true } - data: - type: object - properties: - config: - $ref: '#/components/schemas/FiatConfig' - - FiatAccountsResponse: - type: object - required: [success, data] - properties: - success: { type: boolean, example: true } - data: - type: object - properties: - 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: - type: object - required: [success, data] - properties: - success: { type: boolean, example: true } - data: - type: object - properties: - holder: - $ref: '#/components/schemas/FiatAccountHolder' - already_linked: - type: boolean - description: | - Present, and `true`, when a submission found this customer already registered as an - account holder. Nothing was sent to the bank a second time. + example: "2026-09-18T10:04:22Z" FiatTransactionsResponse: type: object @@ -7864,91 +7584,23 @@ components: type: array items: $ref: '#/components/schemas/FiatTransaction' - 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: + 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 From a65d165a250127a96cfb6df194fdf1f1b9a2a698 Mon Sep 17 00:00:00 2001 From: hallstain Date: Fri, 18 Sep 2026 20:10:10 +0300 Subject: [PATCH 6/7] Carry the rebuild into the order and the customer (UHT-548) The fiat account tag was already right; the two places that reference it from outside were not. - An on-ramp order's `fiat_funding_source` is `own_account`, not `own_iban`, and the order names the `fiat_account_id` it is funded from. The three prose references to the old value follow. - `GET /users/{user_uuid}` documents `fiat_account_issuance` (`ready`, `missing_fields`), which is the only thing this rebuild adds to User Management. - The guide says up front that it is the how-to and the reference is the Fiat Accounts tag, so the two are not read as alternatives. Co-Authored-By: Claude Opus 5 (1M context) --- fiat-accounts.md | 3 +++ openapi/swagger.yaml | 46 +++++++++++++++++++++++++++++++++++--------- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/fiat-accounts.md b/fiat-accounts.md index 4938926..edeb6ee 100644 --- a/fiat-accounts.md +++ b/fiat-accounts.md @@ -1,5 +1,8 @@ # Fiat accounts +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. diff --git a/openapi/swagger.yaml b/openapi/swagger.yaml index 41311d4..fbcd960 100644 --- a/openapi/swagger.yaml +++ b/openapi/swagger.yaml @@ -2309,7 +2309,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. @@ -5664,6 +5664,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: @@ -6701,7 +6720,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) @@ -6774,7 +6793,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, @@ -6783,17 +6802,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 @@ -6966,7 +6994,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 From 9b4bd2d29b70b43267baabcda49000779b81b392 Mon Sep 17 00:00:00 2001 From: hallstain Date: Mon, 21 Sep 2026 14:10:39 +0300 Subject: [PATCH 7/7] docs: say plainly that partner USD payouts are not offered yet The USD rails to China, Hong Kong and Singapore can show up in the catalog, but quote and initiate refuse them with provider_confirmation_pending. Say so in the payouts guide and the changelog instead of publishing the retail bill-payment flow (supersedes #64 and #66). Co-Authored-By: Claude Opus 5 (1M context) --- api-reference/third-party-payouts.md | 11 +++++++++++ changelog.md | 4 ++++ 2 files changed, 15 insertions(+) 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 76cbda4..fd42e97 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,10 @@ 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.