For the complete documentation index, see llms.txt. This page is also available as Markdown.

Changelog

Notable changes to the Unigox partner API, newest first.

2026-10-09

Unused fiat accounts are closed automatically. An account with no transactions for a period set by Unigox (30 days by default) is closed, and you receive fiat_account.updated with status: closed, as for any close. To serve the customer again, open a new account with the usual request; it comes with new pay-in details. See Unused accounts are closed.

2026-10-08

On-ramp send-out uses the same networks as the withdraw screen on unigox.com. GET /api/v1/partner/send-out/routes?crypto=USDT is new: it lists the chains a completed on-ramp order of that crypto can be withdrawn to. Pass destination_chain to bridge-authorization-parameters and authorize-bridge exactly as listed (for example Arbitrum One, BNB Smart Chain), or the chain_id. The order's own crypto is sent, so a USDC order now goes out as USDC. Lowercase slugs such as arbitrum are no longer accepted. Tron, Solana and TON addresses are accepted in their own formats.

2026-09-23

GET /api/v1/partner/stats is new. It returns total_orders, completed_orders, and volume_usd and earned_usd over completed orders, each order converted to USD at the rate snapshotted when it was created. This is what the partner dashboard tiles now show; they previously summed the first page of the orders list, which is served pending-first, so they could show a stale unaccepted request in its own fiat currency instead of your completed volume.

A reminder, since it came up: the markup you set in the partner dashboard applies to trades made through the embedded widget only. An API order carries a markup only when its quote or estimate request sends partner_fee_pct; omitting the field means no markup on that order.

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.

2026-09-18

Fiat accounts are rebuilt around one id, and the identity endpoints are gone. An account a customer is paid into is now a resource of its own: POST /api/v1/partner/fiat-accounts with {user_uuid, currency} opens it, and everything about it is addressed by fiat_account_id. This replaces the whole previous surface. There is no compatibility window — the old routes are removed.

  • One id, called fiat_account_id. A uuid we mint, the way user_uuid names a person. There is no field called id on an account, no retail_ prefix, and nothing that names the bank behind it. It is what the path takes, what a webhook names the account by, and what an order funded from the account carries.

  • GET /users/{user_uuid}/identity and /identification are removed. They were the banking vendor's own KYC push in partner clothing, and they asked you to maintain a second identity for a person you had already verified with us. Everything the bank needs now comes from the KYC record. When it needs a field that record does not have, issuance answers 422 ISSUANCE_NOT_READY and names the fields the way PATCH /api/v1/partner/users/{user_uuid}/kyc takes them — address, city, postal_code, dob, id_number, id_type. GET /api/v1/partner/users/{user_uuid} carries the same answer up front, under fiat_account_issuance.

  • Accounts are no longer nested under the customer. GET /fiat-accounts?user_uuid=, GET /fiat-accounts/{fiat_account_id} and …/transactions replace the /users/{user_uuid}/fiat-accounts tree. An account id is enough to read an account; one that is not yours answers 404.

  • An account has a status you can act on: pending, active, failed, closed. A pending account carries no pay-in details, because there is nowhere to pay in yet. fiat_account.updated fires when that changes.

  • A deposit has its own webhook at last. fiat_account.deposit.received fires on every credit that lands on an issued account, carrying transaction_id, the amount, and order_id when the deposit funded an on-ramp. Previously nothing told you money had arrived unless collection into your own account was switched on, which for most partners it is not.

  • retail.settlement.completed is no longer published. Collecting a customer's deposit into your operating account is an internal arrangement most partners do not have on, and it was never the event you needed: the deposit is. It will come back, named from your side, if and when collection ships as a product.

  • One transactions list. GET /fiat-accounts/{fiat_account_id}/transactions replaces the separate ledger and payments reads. v1 is receive-only, so every row is a credit, and a credit that funded an order names it.

  • On-ramp orders paid from the customer's own account say fiat_funding_source: "own_account" (it was own_iban, which was wrong the moment the product covered sterling) and carry the fiat_account_id they are funded from. next_action is still deposit_to_user_account and confirm-payment-sent is still refused with 409 OPERATION_NOT_ALLOWED.

  • Gone with the removed routes: CLIENT_NOT_APPROVED, ACCOUNT_HOLDER_NOT_FOUND, IDENTIFICATION_MISSING, IDENTIFICATION_ALREADY_LINKED, HOLDER_REGISTRATION_IN_PROGRESS and HOLDER_UNAVAILABLE. New: KYC_NOT_CLEARED and ISSUANCE_NOT_READY.

2026-09-15

Interac e-Transfer no longer asks for an institution_id the catalog said it did not need. /api/v1/supported/payment-rails reports institution_required: false for interac-e-transfer — an e-Transfer is addressed to the recipient's email or phone number, not to a bank you pick — but creating a destination on it without an institution_id failed with institution_id is required for this rail. A partner following the catalog was refused, which was our bug, the same one fixed for the Chinese wallet rails on 2026-08-28.

  • Omitting institution_id on interac-e-transfer now works. The destination resolves to interac-e-transfers and goes through the same checks as if you had named it.

  • Sending an institution_id is still accepted: interac-e-transfers, or any bank /api/v1/supported/institutions?rail=interac-e-transfer lists.

  • Destinations you already created are unchanged.

2026-09-14

An on-ramp order can now be paid from the fiat account you issued the customer. Instead of wiring the order's fiat to a vendor and calling confirm-payment-sent, the customer transfers the amount into their own account and the order moves on by itself.

  • The order says which applies. On-ramp orders carry fiat_funding_source: own_iban (pay into the customer's own account) or vendor_details (pay the vendor, as before). Read it on every order — whether an order is own_iban depends on the customer holding a payable account in the order's currency, not on a setting you pass.

  • On an own_iban order there are no vendor_payment_details, next_action is deposit_to_user_account while the order waits for fiat, and confirm-payment-sent is not in allowed_actions. Calling it anyway answers 409 OPERATION_NOT_ALLOWED: confirming by hand would take the order out of the state the incoming transfer is matched against.

  • Open the order first, then have the customer transfer. A transfer 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.

  • A deposit that matches no open order is handled as before: it stays on the customer's account, or is collected into your master account with the usual retail.settlement.completed webhook if we have switched collection on for you.

2026-09-10

Fiat accounts are now reached through the customer that holds them, and the flat /partner/fiat-accounts tree is gone. This follows yesterday's entry and replaces the account routes it introduced. An account is not a free-standing thing you own — it belongs to a person, that person is the customer you created with POST /api/v1/partner/users, and holding an account id was enough to read an account without holding the relationship behind it.

  • The account routes moved under the customer. GET /partner/users/{user_uuid}/fiat-accounts/{id}, …/{id}/ledger and …/{id}/payments replace GET /partner/fiat-accounts/{id} and its children. Listing and issuing were already customer-scoped and are unchanged.

  • GET /partner/fiat-accounts — the list of every account across all your customers — is removed. It was the one route that could not name a holder. Read a customer's accounts with GET /partner/users/{user_uuid}/fiat-accounts.

  • GET /partner/fiat-accounts/config stays where it is. It answers what you may offer at all, before any customer is in scope, and it is not an account.

  • An account is only addressable under its own customer. One that exists but belongs to a different customer answers 404, exactly as one that does not exist. An account id alone is no longer enough to read an account.

  • Business-held accounts are off this API. Accounts issued to a company you onboarded through KYB have a KYB case as their holder, not a customer, so they cannot be addressed through this tree. They remain available in the Unigox console. holder_type and the retail_ id prefix stay as they are — they are how you will tell holder kinds apart if another becomes available, and changing the id format twice would be worse than publishing a constant once.

  • Gone with the removed routes: INVALID_HOLDER_TYPE and LEDGER_NOT_ATTRIBUTABLE.

2026-09-09

Retail accounts are now Fiat accounts, on the standard envelope, scoped to the customer you already have. This replaces the /api/v1/partner/retail/* endpoints, which are removed. The product is unchanged — you still issue dedicated IBANs for your own customers and read their balances, ledger and payments. What changes is the shape, and every part of it was a departure from the rest of this API that should not have shipped.

  • The endpoints moved under the customer. There is no separate "retail client" to register, and no POST .../clients step. An account holder IS the customer you created with POST /api/v1/partner/users — that was always true in our data model, and publishing it as a second entity gave you two identities and two KYC statuses to keep in step for one person. Submitting an identity is now POST /api/v1/partner/users/{user_uuid}/identification, and issuing is POST /api/v1/partner/users/{user_uuid}/fiat-accounts. Reads live at /api/v1/partner/fiat-accounts.

  • Identity comes from the verification, not from your request body. Every field on the identification request is now optional; what we verified at KYC is preferred over anything you send. Call GET /api/v1/partner/users/{user_uuid}/identity and send only what it lists under missing. You are no longer asked to retype a name, a country or a document we already read off the customer's ID.

  • Polling a verification is a GET. GET /api/v1/partner/users/{user_uuid}/identification replaces the old POST .../identification/refresh. Checking a status never changed anything; it should not have needed a write.

  • The envelope is the standard one. {"success": true, "data": { … }} on success, and {"success": false, "error": {"code", "message", "details"}} on failure — the same PartnerErrorResponse the order and off-ramp endpoints use. Error codes are SCREAMING_SNAKE like everywhere else (MISSING_FIELDS, CLIENT_NOT_APPROVED, PROVISIONING_IN_PROGRESS), and a validation failure lists the fields under error.details.missing_fields. The old flat {"ok", "source", "error", "detail"} envelope is gone, along with the source-is-sometimes-an-object exception on the account list.

  • Field names are snake_case, at every depth, matching the rest of the API. They were camelCase.

  • Nothing identifies our banking provider any more. source, upstream, the depa* identifiers and the raw provider status are all removed, and the error codes that named the provider (depa_error, depa_unavailable) are now BANKING_ERROR and BANKING_UNAVAILABLE. Which institution issues a given currency is an operational detail we may change, and no integration should have been able to tell. bank_name and bic stay — those describe the account a payer sends money to, which is yours to display.

  • Business-held accounts are on this API for the first time, on the same endpoints. An account you issued to a company you onboarded through KYB was previously readable only in the console, under its own business-customers/{caseId}/accounts/{bankAccountId} tree and a third response envelope. It now appears in GET /api/v1/partner/fiat-accounts beside your retail ones, in the same shape, and its ledger and payments are read through the same routes. holder_type (retail | business) says whose account it is and holder_id names them in that kind's own id space — a customer user_uuid, or a KYB case id. Filter with ?holder_type=; if you were never set up for business accounts you simply have none, which is not an error. Issuing one still needs a verified KYB case and stays a console flow — these are reads.

  • Account ids are opaque and carry their holder kind: retail_412, business_87. Pass them back verbatim and do not parse them. The two kinds are stored separately and their row numbers overlap, so the prefix is part of the id rather than a parameter beside it. The console's business listing used to publish the banking provider's own bank-account id as the account's identifier; it no longer travels.

  • Unchanged: enablement is still Unigox-side (retail product, issue_retail_accounts capability), money movement is still not on this API, issuance is still idempotent per (customer, currency, jurisdiction), and deposits still stay on the customer's account unless we have switched collection on for you — with the same retail.settlement.completed webhook when it settles.

2026-09-08

You can now return a funded escrow to the seller yourself. An off-ramp order that stops before settlement — the payment window expires, a payment proof is declined, the bank returns the payment, or a dispute is resolved for the seller — leaves the crypto in escrow owing its way back. Two new endpoints let you sign that refund with your own key, the same shape as the funding pair you already use: GET /api/v1/partner/orders/{order_id}/refund-authorization-parameters returns the EIP-712 Safe transaction, and POST /api/v1/partner/orders/{order_id}/authorize-refund takes the signature.

  • Sign with signer_address, which the GET returns. The escrow is a 2-of-3 Safe; on an order you opened for your end customer, its seller-side owner is your wallet, and the customer holds no key at all. Unigox adds the second signature and executes.

  • The recipient is fixed server-side to the escrow's seller address. recipient_address comes back so you can verify it before signing; no parameter changes where the money goes.

  • Your private key never reaches the API. Sign the returned typed data locally and submit only the signature.

  • An order that has already been refunded or released, or whose status does not owe the crypto back, answers 409 INVALID_STATUS. An order held for review answers 409 OPERATION_NOT_ALLOWED — a held order is settled by our operations team, and retrying will not clear it. A signature from a key that does not own the escrow answers 502 — check signer_address and retry.

  • tx_hash comes back on the response when the refund executed. If it is absent the refund is still in flight; poll GET /api/v1/partner/orders/{order_id}.

And the order now tells you when that refund is owed. The endpoints above are only useful if you know to call them, and until now nothing said so: an order stuck this way reads cancelled, which is also what an order reads when it never funded and when its refund was paid out months ago. The status cannot tell those apart, so two other things now do.

  • On the order. allowed_actions contains authorize-refund while the crypto is still in the escrow, with next_action: "authorize_refund" and an action_required block naming the endpoint. It appears only when there is something to send back — a funded escrow nothing has moved out of — so it is never a dead end. cancelled is the one terminal status worth checking allowed_actions on.

  • By webhook. A new event type, order.refund.required, fires at most once per order, carrying the same action_required block. It is separate from order.status.changed because there is no status change to carry it — the order has read cancelled since the day it stopped. No subscription change is needed; if you receive order.status.changed today you will receive this.

Neither appears on a widget order: your customer funded that escrow and signs for it in our app, and the refund endpoints answer 404 ORDER_NOT_FOUND for you there, as every other action endpoint does.

Correction to the 2026-07-13 fee entry. It said cancelled or failed orders "refund the full amount, markup included". The amount was right; the automatic refund was not. Nothing is deducted from an order that does not settle, but once the escrow has been funded the crypto stays in it until someone signs — which, before today's endpoints, meant contacting us.

2026-08-31

A sender field too long for the payout rail is now refused up front, with the limit in the response. The rails behind SENDER_IDENTITY_REQUIRED cap several sender fields, and they enforce the cap at payout: the whole payment is rejected, after the customer has funded, and the customer sees only that the order was cancelled. A value over the cap is now treated as a field the record could not answer, so POST /api/v1/partner/offramp/initiate refuses it while a retry is still free.

  • error.details.field_limits carries the rail's ceiling on any listed field that has one, keyed the way you write it in the KYC patch: {"address": 70}. It is absent when no listed field has a ceiling.

  • Read the ceilings from there rather than hard-coding them. They are the payout vendor's, they differ per corridor, and they are counted in characters, not bytes — a Chinese address is three bytes per character.

  • On the Chinese wallet corridor today: first and last name 60, address 70, city 50, id_number 30. postal_code has no published ceiling, and phone_number needs none because the format it already has to be in is shorter than the rail accepts.

  • Nothing changes for a record already within the limits, and missing_fields and kyc_fields keep their existing meaning. What changes is that a field can now appear because its value is too long, not only because it is absent — which is why the ceiling ships alongside it.

  • PATCH /api/v1/partner/users/{user_uuid}/kyc still accepts an over-long value: the ceiling belongs to the payout corridor, and the KYC record is not tied to one. It comes back from that corridor's initiate.

2026-08-28

The Chinese wallets are their own rails, and institution_id is not required on them. Alipay and WeChat Pay are no longer formats of cnaps: they are the rails alipay-wallet and wechat-wallet, each carrying one wallet and one format (full_name, account_number, mobile_number). cnaps now carries the two bank formats only.

  • /api/v1/supported/payment-rails reports institution_required: false for both wallet rails, and creating a destination on them without an institution_id now works. It previously failed with institution_id is required for this rail — a partner following the catalog was refused, which was our bug, not a missing field on your side.

  • Sending the rail's own institution (alipay, wechat-pay) is still accepted and means the same thing.

  • Nothing changes for cnaps bank destinations, which continue to require an institution_id.

Fewer sender fields come back on a payout to those wallets. The sender identity behind 422 SENDER_IDENTITY_REQUIRED now reads the identity document from a KYC verification you performed and shared with us, not only from our own. For a customer verified that way, the name, date of birth, document type and number, issuing country, nationality, gender and country of residence are taken from that verification instead of being reported missing.

  • What remains in missing_fields is what no identity document carries: the residential address (address, city, postal_code), the mobile number and the source of funds. All five are in kyc_fields, so PATCH /api/v1/partner/users/{user_uuid}/kyc can supply them.

  • Retry with a fresh quote after the patch, as before.

2026-08-26

allowed_actions is now empty for a widget order. The field is derived from crypto custody as well as from status, so it no longer lists actions that would answer 404. A widget order returns "allowed_actions": [] at every status — including awaiting_crypto_transfer_authorization, where it used to advertise ["authorize-crypto-transfer", "cancel"] and both calls then failed.

This closes the gap flagged in the 2026-08-25 entry below. On GET /api/v1/partner/orders/{order_id} and GET /api/v1/partner/orders, allowed_actions has become a reliable signal: it is derived from the same custody rule the action endpoints enforce, so an action listed there is one that endpoint will accept.

  • Nothing changes for orders you created through this API. They advertise exactly what they advertised before, at every status.

  • Reading a widget order is unchanged. It still appears in listings and webhooks, and status, next_action and timeline still describe it — they describe your customer's step, not one for you to take. Only the advertisement of your actions is gated.

  • The guarantee runs one way only. A widget order is always [], but [] does not mean an order is a widget order — your own orders are also [] whenever their status carries no action for you, including the mid-flight crypto_transfer_authorization_pending, fiat_received_confirmed and crypto_release_pending. Do not use an empty array to infer that an order is a customer's; it would misclassify your own live orders.

  • Keep acting only on order ids your own initiate call returned. That rule still holds as defence in depth: it does not depend on reading a field correctly, and it is what the action endpoints themselves enforce.

Retail accounts: issue dedicated fiat accounts (IBANs) for your own end customers. A new optional product lets an entitled partner register retail clients under its customers, push their identity to our banking provider, issue accounts, and read balances, ledger and incoming fiat payments — all under the existing X-API-Key. See the Retail accounts reference and the Retail Accounts endpoint group.

  • The envelope is different for these endpoints. Retail responses are flat: {"source":"depa","ok":true, …} on success (the payload merged at the top level, not under data), and {"ok":false,"source":"depa","error":"<slug>","detail":"…"} on error. Branch on error. Provider-origin failures add upstream; missing-field errors add a missing array. GET /retail/accounts returns source as an object, not the string "depa".

  • Enablement is Unigox-side. You reach these endpoints only once we have activated the retail product on your partner and, for issuing, granted issue_retail_accounts. Until then writes answer 403 and GET /retail/config reports enabled: false. Money movement (funding, swaps, closing, outbound payments) is not on this API.

  • Deposits stay on the customer's account by default. When money lands on a retail IBAN you issued, it stays on that customer's own account and you read it via …/fiat-payments. We can, per partner, switch on collection of those credits into your master account instead — ask us to enable it. Only when collection is on and succeeds do you receive a new retail.settlement.completed webhook (same envelope and signature as other events); on the default there is no deposit webhook.

Widget callbacks now carry the order id. The embedded widget's onTradeStarted and onTradeCompleted callbacks include orderId alongside tradeId. It is the same order_id this API and your webhooks use, so a widget trade can now be read directly with GET /api/v1/partner/orders/{order_id}.

Previously there was no way to make that call. tradeId is a Unigox-internal numeric id, and this API validates the path segment as a UUID — passing tradeId answers 400 invalid order_id format, not a 404. The order UUID was not exposed to the widget and the order response carries no trade id, so the two identifiers had nothing to join on.

  • orderId is optional in the callback payload, because a trade opened outside a partner widget has no partner order behind it. Read it defensively rather than asserting it.

  • tradeId is unchanged and still correlates the widget's own events (onTradeStarted, onTradeCompleted, onSendout*) with each other.

  • This does not make widget orders actionable. They remain notify-only — see below.

2026-08-25

Widget orders are notify-only. If your customers reach Unigox through the embedded widget under your widgetKey, the orders they create are attributed to you — they appear in GET /api/v1/partner/orders and in your webhook stream, exactly as before. What changes is that they are no longer actionable: every action endpoint (authorize-crypto-transfer, confirm-fiat-received, cancel, submit-payer-details, confirm-payment-sent, authorize-bridge, and the two authorization-parameter reads) now answers 404 ORDER_NOT_FOUND for them.

The crypto on a widget order belongs to the end user and sits in their own Unigox wallet, while every partner action moves crypto from your wallet. Funding a widget order's escrow therefore paid for a customer's trade out of your balance, and a refund on that order paid the customer rather than returning your funds. Refusing the action is what stops that.

  • Nothing changes for orders you created through this API. They remain fully actionable.

  • Webhooks for widget orders keep arriving, so you can still correlate a widget trade with your own records. Treat them as notifications, not as instructions: an awaiting_crypto_transfer_authorization status on a widget order is describing the customer's step, not asking you to take it.

  • Neither status nor allowed_actions distinguishes a widget order yet. allowed_actions is derived from status alone, so a widget order sitting at awaiting_crypto_transfer_authorization still advertises ["authorize-crypto-transfer", "cancel"] — and both now return 404. Until that is fixed, the reliable rule is the one below.

  • Only act on orders you created. Keep the order_id returned by your own POST /offramp/initiate or POST /onramp/initiate call and act on those. Any order id you first learned about from a listing or a webhook came from the widget, and is not yours to act on.

2026-08-11

Chinese mobile wallets pay Chinese nationals only, and WeChat pays fewer of them than Alipay. Both wallets verify the account holder against a Chinese national ID, so a foreign resident's Alipay or WeChat account cannot receive on the ewallet format — use a bank format for those beneficiaries.

  • alipay settles to the sender themselves, to family, or to a third party.

  • wechat-pay settles to the sender themselves or to family only. A wechat-pay destination quoted as supplier, employee or friend is refused before any money moves.

  • The receiving wallet may ask the beneficiary to evidence the declared relationship (proof of income for a payment to the sender themselves, proof of the relationship for family). A relationship the beneficiary cannot evidence is what leaves a payment held inside the wallet.

  • Paying a Chinese company from your company (B2C) is not available on this rail yet.

Nothing changes for the bank formats on cnaps, which carry no such restriction.

2026-08-07

Consumer-to-consumer payout rails now require the sender's own identity. Chinese mobile wallets (Alipay, WeChat Pay — the ewallet format on cnaps) settle person to person: the remitter shown on the receiving wallet must be your paying customer, not Unigox and not your company. When the customer's KYC record cannot name them, POST /api/v1/partner/offramp/initiate returns 422 SENDER_IDENTITY_REQUIRED. No order is created and the quote is reverted.

  • error.details.missing_fields is what the rail needs; error.details.kyc_fields is the subset you can supply, as the request body of PATCH /api/v1/partner/users/{user_uuid}/kyc. Send them, then retry with a fresh quote.

  • That endpoint gains four fields for this: gender (M/F), nationality and id_issue_country (ISO 3166-1 alpha-2), and source_of_funds (a fixed vocabulary — see the endpoint). id_type also accepts RESIDENCE_PERMIT. An unacceptable value is a 400 with invalid_gender, invalid_country_code, invalid_source_of_funds or invalid_field_value (a value sent as an object or an array), and nothing is written — the patch is all-or-nothing.

  • A recognised field sent blank is a no-op, not an error: the response is 200 and the field is absent from updated_fields, so a patch whose recognised fields are all blank answers {"updated_fields": []} and the payout still refuses. Read updated_fields before retrying.

  • When kyc_fields is shorter than missing_fields, the remainder (the name on the document, the country of residence) can only come from verification itself — the customer has to complete or redo KYC.

Nothing changes for bank-format payouts, on-ramp, or any corridor other than the wallet formats. Send the customer's own details: substituting anyone else's is what the rail's identity check exists to catch.

2026-07-29

Third-party recipients are now first-class Partner API resources. A KYC-verified sender can pay a separately registered recipient without representing that recipient as one of the sender's own payment methods.

  • Register and manage recipients under /api/v1/partner/recipients, including a versioned payout destination.

  • Bind sender_recipient_relationship and purpose_of_payment for each payment.

  • Quote an off-ramp with sender_id, recipient_id, and recipient_destination_id; omit payment_details_id for this ownership mode.

  • Quote and order responses expose the immutable recipient_context. Initiation creates a durable compliance case visible in the same Unigox Compliance queue used for portal-created payouts.

  • Sensitive identity and account values are write-only or masked on reads. Vendor-specific beneficiary terminology is not exposed by the Partner API.

The KYC user identified by sender_id must be the real party funding the payout. Partners must not route multiple real customers through a shared shell sender.

2026-07-22

New KYC status UNDER_REVIEW for customers under manual compliance review. When a customer's screening needs human compliance review (rather than a quick automated check), their KYC status is now surfaced as UNDER_REVIEW instead of IN_PROGRESS. It appears wherever a KYC status is reported: GET /partner/users/{user_uuid}/verification-status, GET /partner/users/{user_uuid} (kyc.status), the KYC submit response (kyc_status), and the user.kyc.updated webhook. A manual review can take up to 24 hours, unlike an automated check of a couple of minutes — so you can now tell the two apart and set the right expectation with your customer.

UNDER_REVIEW is not cleared: treat it exactly like IN_PROGRESS for gating — keep polling and do not unlock onramp/offramp until the customer reaches VERIFIED. This is an additive status value; if you already wait for VERIFIED before transacting, no action is needed.

2026-07-21

On-ramp and off-ramp price estimates are public. POST /api/v1/partner/onramp/estimate and POST /api/v1/partner/offramp/estimate no longer require an API key — same as GET /api/v1/partner/liquidity. They return indicative pricing only (no liquidity reservation, no quote or order). Executable pricing and order creation still need a key: POST /partner/onramp/quote, /onramp/initiate, /offramp/quote, and /offramp/initiate.

No code changes required if you already call estimate with a key; the key is simply optional for these two endpoints.

2026-07-13

You can now charge your end users a per-order markup. Pass an optional partner_fee_pct on the quote and estimate requests for both ramps (POST /onramp/estimate, /onramp/quote, /offramp/estimate, /offramp/quote) — 1 means 1%. The markup is declared per order (no stored config), computed in crypto on the same base as the platform fee. For off-ramp, the markup is settled on-chain to your partner wallet when the order releases successfully; an order that does not settle is never charged the markup, and the refund returns the full funded amount with the markup in it. (Corrected 2026-09-08: this originally said cancelled or failed orders "refund the full amount", which read as if the refund happened by itself. It does not once the escrow has been funded — see the 2026-09-08 entry.) For on-ramp there's no separate transfer leg: the full buyer amount lands in your partner wallet at release, and the markup is captured by withholding it when the crypto bridges out at send-out (a crypto-anchored order is sized up beforehand so the requested amount still arrives net of the withheld fee).

  • Quote, estimate, initiate, and GET/LIST order responses report the real figures in fee_breakdown.partner_fee and fee_breakdown.partner_fee_pct, and webhook event data carries partner_fee / partner_fee_pct too. The compact action responses (confirm-payment-sent, confirm-fiat-received, cancel, authorize-crypto-transfer) don't carry fee_breakdown — re-fetch GET /orders/{order_id} for the fee figures after those calls.

  • crypto_amount stays partner-fee-exclusive in composition, with two exceptions to what it's exclusive of: a crypto-anchored onramp order (request declares crypto_amount) is sized up so the delivered amount stays exact after the markup is withheld, so crypto_amount there includes the markup; a crypto-anchored offramp order (request declares crypto_amount) carves both fees out of the pinned deposit, so crypto_amount and platform_fee both move relative to a no-markup request even though the markup itself stays out of crypto_amount. Every other case is unaffected — the markup shows only in fee_breakdown (and in the offramp funding amount from transfer authorization, which is crypto_amount + partner_fee). Apply your fee on the same side the platform does, or your receipts won't reconcile with our orders.

  • Fiat-anchored offramp orders have no cap — the deposit grows to cover the markup. Every other case hits a formula domain bound, not a business cap: onramp rejects partner_fee_pct >= 100 (the withheld or grossed-up fee would consume the entire buyer amount, leaving nothing delivered); crypto-anchored offramp rejects it when the fee would consume the entire pinned deposit.

Pricing is unchanged if you don't charge a markup: omit partner_fee_pct (or send 0) and every amount is what it was — only partner_fee and partner_fee_pct read zero. The platform fee still applies, so platform_fee and total_fee keep reporting it (with total_fee equal to platform_fee).

One formatting change does reach every partner, including those not charging a markup: in REST responses, fee_breakdown.partner_fee previously always returned the literal "0.00" and now carries the token's own decimals, like the other fee fields — a zero-fee USDT order reports "0.000000". The value is unchanged; parse it as a decimal rather than comparing strings. This does not apply to the webhook's data.partner_fee field, which always used (and still uses) the shortest round-tripping representation — "1", not "1.00"; "0" for no fee.

2026-06-27

Customers must be KYC-cleared before onramping or offramping. Quote and initiate calls (POST /onramp/quote, /onramp/initiate, /offramp/quote, /offramp/initiate) return 422 KYC_NOT_CLEARED if the customer is not cleared; error.details.kyc_status carries their current status. Poll GET /partner/users/{user_uuid}/verification-status until the customer reaches VERIFIED.

No action needed if you already wait for VERIFIED before transacting.

2026-06-24

Recipient validation moved to recipient creation. The recipient field check that previously ran when you created an offramp order now runs earlier — when you create the recipient (POST /api/v1/partner/users/{user_uuid}/payment-details):

  • If a required recipient field is missing or malformed for the payout corridor, the create is rejected with 422 Unprocessable Entity and the offending fields are named in the error message — so you fix the recipient once, up front, before it can be used in any order.

  • POST /api/v1/partner/offramp/initiate no longer returns RECIPIENT_UNVERIFIABLE: a recipient you successfully created has already passed field validation. Quote and funds checks at initiate are unchanged.

  • This is now the single point of recipient validation — there is no second check at order time. It confirms the recipient details are complete and well-formed for the corridor; it does not by itself guarantee the destination account exists. If the validation service is temporarily unavailable the recipient is still created (creation is never blocked by an outage), so create recipients before you need them.

No action needed if you already create recipients before ordering. Corridors without recipient requirements are unchanged.

2026-06-22

Offramp orders now validate the recipient details before the order is created.

For payout corridors with recipient requirements (e.g. mobile money and bank transfers in Nigeria, Ghana, and Kenya), POST /api/v1/partner/offramp/initiate now checks the recipient up front:

  • If a required recipient field is missing or malformed, the request is rejected with 422 RECIPIENT_UNVERIFIABLE and the offending fields are listed in error.details.fields — so you can fix the details and create a new order, instead of creating one that can't be paid out.

  • Name requirements are corridor-specific: bank transfers expect the account-holder name, while mobile money pays to the number and accepts a placeholder name.

  • This confirms the recipient details are complete and well-formed; it does not by itself guarantee the destination account exists. If the up-front check can't be completed, the order is still created and the recipient is re-checked before payout — so an outage never blocks order creation.

Corridors without recipient requirements are unchanged; no action required.

Last updated