Migrating from v2 API to v3 API

The v3 API is the current TransFi API. It replaces the v2 API with a single order endpoint,
one response shape, one error shape, and users addressed by userId rather than email. This
entry maps every v2 endpoint to its v3 equivalent and lists the request and response changes you
need to make, so you can migrate one call at a time.

Nothing changes for you until you switch a call from /v2/... to /v3/.... The two versions
run side by side, so you can move one endpoint at a time. v2 has been in maintenance mode since
1 October 2026 and will be switched off on 1 December 2026.

Key dates

MilestoneDate
v3 available in sandbox and productionAvailable now
v2 in maintenance mode (bug fixes only, no new features)Since 1 October 2026
v2 end of life (all /v2/... calls stop working)1 December 2026

What is unchanged

  • Authentication. HTTP Basic with the same username and password. Your credentials work on both versions.
  • Base URLs. https://sandbox-api.transfi.com and https://api.transfi.com.
  • The MID header. Send it on every request, as today. The rules for it have not changed.
  • Identifiers. userId (UX-…), orderId (OR-…), prefund ids (PF-…) and invoiceId (IN-…) are the same objects in both versions.
  • Webhooks. Configured in the same place in the dashboard, signed with the same HMAC-SHA256 X-Transfi-Hmac-Hash header, retried on the same schedule, acknowledged with any 2xx. See Webhooks for payload changes.
  • Purpose codes. The same purposeCode values, and purposeCodeReason is still mandatory when purposeCode is other.

The five changes that affect every integration

1. Users are referenced by userId, not email

In v2, orders, KYC, KYB and invoices identified the end user by email. In v3, every one of
those calls takes the userId returned when you created the user. If you only store emails
today, look the userId up once with GET /v3/users/individual?email=… or
GET /v3/users/business?email=… and store it. New users are created by the role they play, as
a sender or a recipient; see Users.

2. One endpoint creates every order

The seven v2 order creation endpoints collapse into POST /v3/orders. The orderType field
selects the flow, and the amount, currency and payment details move into a source block
(where the money comes from) and a destination block (where it goes). See
Orders below for the field-by-field mapping.

3. One response envelope

Every v3 success response is:

{
  "status": "success",
  "data": { ... },
  "traceId": "18c56011-810a-4d55-9f33-23dc590fc6ba"
}

List endpoints add a pagination object:

"pagination": { "total": 15, "pages": 3, "currentPage": 1, "limit": 5, "hasNext": true, "hasPrev": false }

v2 responses had no single shape: some returned the payload at the top level ({ "orderId": … },
{ "userId": … }), some under users, paymentMethods or balance, some under
message.data, and some under status / data. In v3 the payload is under data, with three
exceptions kept for compatibility: GET /v3/exchange-rates returns a flat object, and
GET /v3/kyc/status/USERID and GET /v3/kyb/status/USERID return the same bare
{ "status", "rejectLabels" } as v2.

Order and user responses carry a traceId, and every error response carries an errorId. Quote
whichever you have when you contact support.

4. One error envelope

{
  "status": "failure",
  "error": {
    "message": "Request validation failed",
    "code": "VALIDATION_ERROR",
    "context": {
      "details": [
        { "field": "source.amount", "message": "Amount must be greater than 0", "code": "INVALID_ARGUMENTS" }
      ]
    }
  },
  "errorId": "2c8d0e5f-3b16-4a79-9d24-7e1f6a0b3c58"
}

v2 returned a flat { "code", "message" }, and a validation failure reported only the first
problem. In v3 the top-level error.code is the category (VALIDATION_ERROR, NOT_FOUND, …)
and the specific reason, for example INSUFFICIENT_BALANCE or KYT_CHECK_FAILED, is in
error.context.details[].code, one entry per failing field. The
error codes page lists, for each code, which
of the two places it appears in.

Two things to build into your parser:

  • Request-shape failures on POST /v3/orders, GET /v3/exchange-rates and
    GET /v3/config/transaction-limits (a missing or mistyped field, an unknown orderType) put
    the array directly at error.details[] rather than error.context.details[]. Read both.
  • Server-side failures are returned as 500 with error.code: INTERNAL_SERVER_ERROR and a
    generic message. Retry with backoff and quote the errorId to support.

Authentication and MID failures (401, 404 CUSTOMER_NOT_FOUND, 400 MID_REQUIRED) keep the
flat { "message", "code" } shape in both versions.

5. Pagination

page and limit are the same query parameters. The response moves total and pages into
the pagination object and adds currentPage, limit, hasNext and hasPrev. The default
limit is 5 everywhere. limit is capped at 100.

Rate limits. v2 had none. v3 limits POST /v3/verification/request-link to 3 requests per
10 minutes per user and channel and GET /v3/config/address/autocomplete to 60 requests per
minute. Exceeding a limit returns 429 with a flat body
{ "message", "code": "RATE_LIMIT_EXCEEDED", "retryAfter" } and a Retry-After header.


Endpoint mapping

v2v3Notes
Configuration
GET /v2/supported-currenciesGET /v3/config/supported-currenciesSame parameters; userType added; response gains pagination
GET /v2/payment-methodsGET /v3/config/payment-methodscurrency now optional; response key paymentMethods → data
GET /v2/config/list-tokensGET /v3/config/list-tokensAdds contractAddress; response gains pagination
GET /v2/config/transaction-limits (undocumented)GET /v3/config/transaction-limitsorderType values payin / payout → deposit / withdraw
—GET /v3/config/list-midsNew
—GET /v3/config/address/autocompleteNew
Exchange rates
GET /v2/exchange-rates/depositGET /v3/exchange-rates?orderType=payin
GET /v2/exchange-rates/withdrawGET /v3/exchange-rates?orderType=payout
GET /v2/exchange-rates/fiat-to-cryptoGET /v3/exchange-rates?orderType=onramp
GET /v2/exchange-rates/crypto-to-fiatGET /v3/exchange-rates?orderType=offramp
GET /v2/exchange-rates/gamingGET /v3/exchange-rates?orderType=gaming
GET /v2/exchange-rates/live-ratesNo equivalentUse the flow-specific quote instead
Users
POST /v2/users/individualPOST /v3/senders/individual or POST /v3/recipients/individualBy the role the user plays; call mandatory-fields first.
GET /v2/users/individualsGET /v3/users/individualPath is singular; response key users → data
POST /v2/users/businessPOST /v3/senders/business or POST /v3/recipients/businessBy the role the user plays; call mandatory-fields first.
GET /v2/users/businessGET /v3/users/businessResponse key users → data
—POST /v3/senders/mandatory-fields, POST /v3/recipients/mandatory-fieldsNew: the fields to send for a corridor
—POST /v3/role-change/*New: add a second role or payout rail to an existing user
—PATCH /v3/users/individualNew: correct name or date of birth
—GET /v3/users/account-typeNew
Verification
POST /v2/verification/request-linkPOST /v3/verification/request-linkSame body; rate limited (3 per 10 minutes per user, 429)
KYC
POST /v2/kyc/standardPOST /v3/kyc/standardemail → userId; response redirectUrl → data.kycUrl
POST /v2/kyc/advancedPOST /v3/kyc/advancedemail → userId; response redirectUrl → data.kycUrl
POST /v2/kyc/share/same-vendorPOST /v3/kyc/share/same-vendoremail, name → userId
POST /v2/kyc/share/third-vendorPOST /v3/kyc/share/third-vendoremail, firstName, lastName → userId; idDocType values upper case
POST /v2/kyc/share/add-docsPOST /v3/kyc/share/add-docsemail → userId
GET /v2/kyc/user?email=GET /v3/kyc/status/USERIDPath parameter instead of email query
—GET /v3/kyc/account-statusNew: per-role, per-tier status
KYB
POST /v2/kyb/standardPOST /v3/kyb/standardemail → userId; response link → data.kybUrl
POST /v2/kyb/advancedPOST /v3/kyb/advancedemail → userId; response link → data.kybUrl
GET /v2/kyb/user?email=GET /v3/kyb/status/USERIDPath parameter instead of email query
—GET /v3/kyb/account-statusNew
Invoices
POST /v2/invoices/createPOST /v3/invoices/createemail → userId
Orders
POST /v2/orders/depositPOST /v3/orders with orderType: payin
POST /v2/orders/gamingPOST /v3/orders with orderType: gaming
POST /v2/payout/orders (fiat payout)POST /v3/orders with orderType: payout
POST /v2/payout/orders with depositDetails (offramp)POST /v3/orders with orderType: offramp
POST /v2/orders/deposit with withdrawDetails.walletAddress (onramp)POST /v3/orders with orderType: onramp
POST /v2/crypto/payinPOST /v3/orders with orderType: payin and a crypto source.currency
POST /v2/crypto/payoutPOST /v3/orders with orderType: payout and a crypto destination.currency
POST /v2/qr-payout/orderPOST /v3/orders with orderType: payout and destination.qrCode
POST /v2/qr-payout/verify-qrcodeGET /v3/config/qr-code-details?qrCode=…&currency=…POST body → GET query; response qrCodeDetails → data, taxId dropped
GET /v2/orders/{orderId}GET /v3/orders/{orderId}Fields renamed, see below
GET /v2/ordersGET /v3/ordersNew filters; response orders → data.transfers
—POST /v3/orders/cancelNew: cancel an offramp order before the crypto deposit
—POST /v3/orders with orderType: swapNew
Prefunding
POST /v2/prefunds/create-fiat-prefundPOST /v3/orders with orderType: fiat_prefund
POST /v2/prefunds/create-crypto-prefundPOST /v3/orders with orderType: crypto_prefund
—POST /v3/prefund-accounts/crypto-walletReturns the standing deposit wallet for a ticker
POST /v2/sandbox/prefunds/create-prefundPOST /v3/simulation/create-prefundSandbox only
Balance
GET /v2/balanceGET /v3/balanceResponse balance → data.balance; adds date and pagination

Configuration

Supported currencies, payment methods, tokens

All three move under /v3/config/. Query parameters keep their names. The response body is
now data plus pagination:

v2 responsev3 response
{ "status", "total", "pages", "data": [...] }{ "status", "data": [...], "pagination": {...} }
{ "total", "pages", "paymentMethods": [...] }{ "status", "data": [...], "pagination": {...} }
{ "data": [...] }{ "status", "data": [...], "pagination": {...} }

Keep sending direction on all three; it has the same values. GET /v3/config/payment-methods
no longer requires currency: without it you get every method, each tagged with its currency.
headlessMode is a boolean rather than the string "true". When no payment method matches, v3
returns 404 NO_PAYMENT_METHODS_AVAILABLE where v2 returned 400 INVALID_ARGUMENTS.
GET /v3/config/list-tokens adds contractAddress to each token.

Exchange rates

The six v2 rate endpoints are one endpoint. Pick the flow with orderType, and name both
currencies explicitly:

GET /v3/exchange-rates?orderType=payin&amount=100&sourceCurrency=EUR&destinationCurrency=USDC&paymentCode=sepa_pull&paymentType=bank_transfer
v2 parameterv3 parameter
currency (deposit, withdraw), fiatTicker (fiat-to-crypto), depositCurrency (gaming), cryptoTicker (crypto-to-fiat)sourceCurrency: what is paid in
balanceCurrency (deposit, withdraw), cryptoTicker (fiat-to-crypto), fiatTicker (crypto-to-fiat)destinationCurrency: what is received or held
amountamount
direction (forward / reverse)direction (forward / reverse), same meaning
paymentCodepaymentCode, plus paymentType (bank_transfer, local_wallet, card)

For gaming, destinationCurrency is the currency you hold the balance in, matching the
destination.currency you send on the order.

Every flow returns the same flat object (no status / data wrapper on this endpoint). The
fields you most likely read today map as follows:

v2 fieldv3 field
data.fiatAmount, message.data.sentAmount, message.data.depositAmountsourceAmount
data.withdrawAmount, data.depositAmount, message.data.receiveAmountdestinationAmount
data.exchangeRate, message.data.cryptoPriceconversionRate
data.fees.processingFee, message.data.processingFee, processingFeeDetails.transfiFeeprocessingFee
data.fees.partnerFee, processingFeeDetails.customerFeecustomerFee
data.fees.totalFee, message.data.totalFeetotalFee
message.data.networkFeenetworkFee
message.data.minLimit, message.data.maxLimitminLimit, maxLimit

GET /v2/exchange-rates/live-rates (the same-currency deposit and withdraw rate) has no v3
equivalent. Request a quote for the flow you are about to execute instead.

Users

Creating users

In v3 you create a user by the role they play in an order. A sender is the party funding an
order (payin, onramp, offramp, gaming, swap). A recipient is the party being paid
(payout). One user can hold both roles.

v2v3
POST /v2/users/individualPOST /v3/senders/individual or POST /v3/recipients/individual
POST /v2/users/businessPOST /v3/senders/business or POST /v3/recipients/business

Creation is a two-step call. First fetch the fields for the corridor:

POST /v3/senders/mandatory-fields
{ "type": "individual", "geo": "ID", "currencyType": "fiat" }

The response lists every field in data.inputs with isMandatory and its format rules, plus a
data.rules array of cross-field constraints (either_required, mutually_required). Build
the body from that and submit it with the same country and currencyType:

POST /v3/senders/individual
{
  "country": "ID",
  "currencyType": "fiat",
  "firstName": "Aryan",
  "lastName": "Rai",
  "email": "[email protected]",
  "phoneCode": "+62",
  "phone": "81234567890",
  "dob": "12-12-2000"
}
{ "status": "success", "data": { "userId": "UX-260326115551596837" }, "traceId": "…" }

Compared with the fixed v2 body:

v2 fieldv3 field
firstName, lastName, email, phone, phoneCode, gender, address.*Same names, but send only the fields mandatory-fields returns for the corridor. address may come back as a nested group and can be submitted as-is
datedob, same DD-MM-YYYY format
countrycountry, plus currencyType (fiat or crypto)
businessName, regNoSame names, when returned by mandatory-fields with type: organization
—accountIdentifier: { "type", "value" } on recipients: the bank account, IBAN, wallet or crypto address you will pay them on

Recipients are split by payout rail. A recipient with a crypto_wallet_address identifier is a
RECIPIENT_CRYPTO; one with a bank account, IBAN or wallet identifier is a RECIPIENT_FIAT.
Senders have no such split. To give an existing user a second role, or a second payout rail,
use POST /v3/role-change/mandatory-fields followed by POST /v3/role-change/individual or
POST /v3/role-change/business. The user keeps the same userId.

Submitting details that match an existing user returns the existing userId with
data.code: USER_ALREADY_EXISTS and a 200, so a retry is safe. v2 also returned the existing
user, but gave you no way to tell it apart from a fresh one. A new user returns 200 rather
than v2's 201.

Email compliance failures still return
409. For individual users the code is in error.code with the userId in error.context; for
business users the userId, message and code are returned under data.

Deprecated: POST /v3/users/individual and POST /v3/users/business still exist in v3 and
accept the v2 body (with phoneCode required alongside phone). They are deprecated and will
be removed. Users created through them cannot use the role-change or account-status endpoints,
so migrate straight to the sender and recipient endpoints.

Listing users

v2v3
GET /v2/users/individuals?email=&userId=GET /v3/users/individual?email=&userId=&phone=&accountType=&page=&limit=
GET /v2/users/business?email=&userId=GET /v3/users/business?email=&userId=&phone=&accountType=&page=&limit=
Response { "total", "pages", "users": [...] }Response { "status", "data": [...], "pagination": {...} }

A filter that matches nothing returns 200 with an empty data array and pagination.total: 0.
v2 returned 404 NOT_FOUND. limit is capped at 100.

Each user in the v3 list carries much more than v2 did: the KYC or KYB status per tier
(basicKycStatus, standardKycStatus, advancedKycStatus; basicKybStatus,
simplifiedKybStatus, standardKybStatus, advancedKybStatus), emailVerification and
phoneVerification, timestamps, and, for users created through the sender or recipient
APIs, accountType and accountIdentifier.

KYC, KYB and verification

Every KYC and KYB call switches from email to userId, and the hosted-flow URL is returned
under data with a consistent name.

Endpointv2 requestv3 requestv2 responsev3 response
POST /kyc/standardemail, redirectUrluserId, redirectUrl{ "redirectUrl", "userId" }data.kycUrl, data.userId
POST /kyc/advancedemail, redirectUrluserId, redirectUrl{ "redirectUrl", "userId" }data.kycUrl, data.userId
POST /kyc/share/same-vendoremail, name, tokenuserId, token{ "email", "status", "userId", "userName" }data.userId, data.kycStatus, data.code
POST /kyc/share/third-vendormultipart with email, firstName, lastName, userId, …multipart with userId, … (no email, firstName, lastName){ "status" }data.userId, data.kycStatus
POST /kyc/share/add-docsemail, additionalDoc, additionalDocTypeuserId, additionalDoc, additionalDocType{ "status" }data.userId, data.kycStatus
GET /kyc/user?email=—GET /kyc/status/USERID{ "status", "rejectLabels" }{ "status", "rejectLabels" } (unchanged)
POST /kyb/standardemailuserId, optional uiEnabled{ "link", "expiresIn" }data.kybUrl, data.expiresIn, data.userId
POST /kyb/advancedemailuserId, optional uiEnabled{ "link", "expiresIn" }data.kybUrl, data.expiresIn, data.userId
GET /kyb/user?email=—GET /kyb/status/USERID{ "status", "rejectLabels" }{ "status", "rejectLabels" } (unchanged)

Three details on POST /v3/kyc/share/third-vendor: idDocType is now upper case
(PASSPORT, ID_CARD, DRIVERS instead of passport, id_card, drivers) and lower case
values are rejected; idDocExpiryDate is now documented as required; and the user must already
exist. v2 created the user on the fly from the submitted details when the email was unknown. v3
does not.

POST /v3/kyb/standard: pass uiEnabled: true. v2 always returned a hosted verification
link. In v3 the hosted link is only returned when you send uiEnabled: true; the field defaults
to false, which runs a direct submission flow and returns data.message with no kybUrl.
POST /v3/kyb/advanced always returns the link.

Status codes you may have special-cased in v2 have changed:

Situationv2v3
Unknown user on a status or KYC/KYB call404 USER_NOT_FOUND400 VALIDATION_ERROR with USER_NOT_FOUND in error.context.details[].code
KYC already submitted (standard, advanced, share)200 with code: KYC_ALREADY_PERFORMED400 VALIDATION_ERROR with KYC_ALREADY_SUBMITTED in error.context.details[].code
Standard KYC prerequisite not met400 BASIC_SCREENING_MISSING400 with BASIC_KYC_NOT_APPROVED, BASIC_SCREENING_IN_PROGRESS or BASIC_SCREENING_REJECTED
Additional docs before standard KYC400 NOT_ELIGIBLE400 with STANDARD_KYC_NOT_APPROVED
User belongs to another organisation401 UNAUTHORIZED_USER400 USER_ORGANIZATION_MISMATCH
KYB initiation while a review is pending400 KYB_UNDER_REVIEWNo longer blocked

New in v3: GET /v3/kyc/account-status and GET /v3/kyb/account-status break verification down
per role (sender, recipient) and per tier, for users created through the sender and recipient
APIs.

POST /v3/verification/request-link is unchanged in shape. It is rate limited to 3 requests per
10 minutes per user and channel. The 429 body is flat, not enveloped:
{ "message", "code": "RATE_LIMIT_EXCEEDED", "retryAfter" }, with a Retry-After header.

Invoices

POST /v3/invoices/create takes userId instead of email. Keep sending invoice,
invoiceType and direction as before. The file limit is 4 MB. The response is 200 with
{ "status", "data": { "invoiceId", "url" } } (v2 returned 201).

Orders

One endpoint, selected by orderType

You used to callNow send orderTypesource holdsdestination holds
POST /v2/orders/depositpayinfiat currency, amount, payment methodholding currency (USDT, EUR, …)
POST /v2/orders/deposit + withdrawDetails.walletAddressonrampfiat currency, amount, payment methodcrypto ticker and walletAddress
POST /v2/orders/gaminggamingfiat currency, amount, payment methodthe holding ticker configured for your account (filled in if omitted)
POST /v2/payout/orderspayoutbalance currency to debitfiat currency, amount, payment method, bank or wallet details
POST /v2/payout/orders + depositDetailsofframpcrypto ticker (and sender wallet)fiat currency, amount, payment method, bank or wallet details
POST /v2/crypto/payinpayincrypto ticker, amountholding currency
POST /v2/crypto/payoutpayoutbalance currency to debitcrypto ticker, amount, walletAddress
POST /v2/qr-payout/orderpayoutbalance currency to debitfiat currency, amount, qrCode
POST /v2/prefunds/create-fiat-prefundfiat_prefundfiat currency, amount, payment methodbalance currency
POST /v2/prefunds/create-crypto-prefundcrypto_prefundcrypto ticker, amountbalance currency

The rule of thumb: whatever the end user or you pays with goes in source; whatever is received
goes in destination. A crypto ticker such as USDCSOL or USDTPOLYGON is passed as a
currency, always with its network suffix. Prefund and QR payout orders carry no userId; a
userId on a crypto prefund turns it into a crypto payin.

Which user to create. For payout the userId is a recipient. For payin, onramp,
offramp, gaming and swap it is a sender. For a user-to-user offramp, omit the root
userId and send source.userId (the sender) and destination.userId (the recipient) instead.

Request field mapping

v2 fieldv3 fieldNotes
emailuserIdRequired for payin, payout, onramp, offramp, gaming, swap; omit for prefunds and QR payouts
amount + currencysource.amount + source.currency (collecting) or destination.amount + destination.currency (paying out)Send exactly one of source.amount and destination.amount
paymentType, paymentCodesource.paymentType, source.paymentCode (collecting) or destination.paymentType, destination.paymentCode (paying out)On fiat payouts destination.paymentType is now required alongside paymentCode
additionalDetailssource.additionalPaymentDetails or destination.additionalPaymentDetailsSame dynamic fields, from the payment methods API
cryptoTickersource.currency or destination.currency
walletAddress (crypto payout)destination.walletAddressPlus destination.additionalPaymentDetails.walletOwner (exchange or self, with userConfirmed: true for self), now required for every crypto destination
depositDetails.cryptoTicker, depositDetails.sendersWalletAddress (offramp)source.currency, source.sendersWalletAddress
withdrawDetails.cryptoTicker, withdrawDetails.walletAddress (onramp)destination.currency, destination.walletAddress
withdrawDetails.currency, .paymentCode, .additionalDetails (instant settlement on a payin)destination.currency, destination.paymentCode, destination.additionalPaymentDetails
balanceCurrency (payin, gaming)destination.currency, or destination.holdingCurrency when a settlementAccountId is given
balanceCurrency (payout), prefundCurrency (crypto payout)source.currencyNow required: it names the balance to debit
balanceCurrency (prefunds)destination.currency
redirectUrlsuccessRedirectUrl + failureRedirectUrlBoth must be https://; optional if a default is configured on your account
partnerContextcustomerMetaDataA root-level partnerContext is silently ignored. The data is still returned as partnerContext when you read the order
partnerIdpartnerIdUnchanged, still your idempotency key. See Idempotency
sourceUrlsourceUrlUnchanged, must include https://
invoiceIdinvoiceIdUnchanged; the invoice must belong to the userId on the order
headlessModeheadlessModeSame name, but see the behaviour note below
deviceDetailsdeviceDetailsSame shape; ipInfo.countryCode3 added. Send it on every order, not only headless ones
purposeCode, purposeCodeReasonpurposeCode, purposeCodeReasonpurposeCode is now required for every order type, including prefunds. other always needs a purposeCodeReason
—customization.locale, customization.paymentDescriptionNew
—settlementAccountIdNew

Response mapping

v2 responsev3 response
orderIddata.orderId
paymentUrldata.payUrl or data.paymentUrl
redirectUrlNot echoed back
partnerContextNot echoed back
walletAddress (crypto payin, crypto prefund, offramp)data.walletAddress, plus data.cryptoCurrency, data.cryptoNetwork
cryptoAmount (offramp)data.cryptoAmount
paymentDetails (fiat prefund, headless payin)data.paymentsData
—data.flow (FIAT_PAYIN, FIAT_PAYIN_WITH_WALLET, FIAT_PAYOUT, CRYPTO_PAYIN, CRYPTO_PAYOUT, ONRAMP, OFFRAMP, FIAT_PREFUND, CRYPTO_PREFUND, FIAT_QR_PAYOUT)
—data.feeData (depositAmount, withdrawAmount, exchangeRate, totalFee)
—data.quoteHistory (the quote applied, including quoteId)
—traceId

Order ids are longer in v3 (OR- followed by 27 digits, against 19 in v2). Do not assume a fixed
length.

Behaviour changes on order creation

  • Headless mode is explicit. v2 switched to headless automatically when you supplied a
    paymentCode and the full additionalDetails. v3 only does so if that is configured on your
    account. Send headlessMode: true when you render your own payment page. Headless payins need
    source.paymentCode and source.paymentType. gaming orders do not support headless mode.
  • Device details on every order. Required on every order, not only headless ones.
  • Compliance declines on the hosted page. When a payin is declined by a compliance check
    before the payment page can be shown, v2 returned 400 with a paymentUrl to an error screen.
    v3 returns 200 with data.payUrl pointing at a /screening-failed page. If you keyed off the
    400, inspect the URL instead.
  • Settlement is account-level. Per-order withdrawDetails and depositDetails are gone.
    Register where you want to be settled with POST /v3/settlement-account and pass
    settlementAccountId.

Before and after

Fiat payin, hosted page

{
  "email": "[email protected]",
  "amount": 100,
  "currency": "EUR",
  "paymentType": "bank_transfer",
  "paymentCode": "sepa_bank",
  "purposeCode": "expense_or_medical_reimbursement",
  "redirectUrl": "https://www.example.com/return",
  "sourceUrl": "https://www.example.com",
  "partnerId": "order-1234",
  "balanceCurrency": "USDC"
}
{
  "userId": "UX-2123123123123",
  "orderType": "payin",
  "purposeCode": "expense_or_medical_reimbursement",
  "successRedirectUrl": "https://www.example.com/success",
  "failureRedirectUrl": "https://www.example.com/failure",
  "sourceUrl": "https://www.example.com",
  "partnerId": "order-1234",
  "source": {
    "currency": "EUR",
    "amount": 100,
    "paymentType": "bank_transfer",
    "paymentCode": "sepa_bank"
  },
  "destination": {
    "currency": "USDC"
  }
}

Fiat payout

{
  "email": "[email protected]",
  "amount": 50,
  "currency": "EUR",
  "paymentCode": "sepa_bank",
  "balanceCurrency": "USDT",
  "additionalDetails": { "iban": "BE71096123456769", "bic": "GKCCBEBB", "street": "…", "city": "…", "postalCode": "…" },
  "deviceDetails": { "userAgent": "…", "ipInfo": { "ip": "186.228.23.255" } },
  "purposeCode": "expense_or_medical_reimbursement",
  "partnerId": "order-12345"
}
{
  "userId": "UX-2123123123123",
  "orderType": "payout",
  "purposeCode": "expense_or_medical_reimbursement",
  "partnerId": "order-12345",
  "deviceDetails": { "userAgent": "…", "ipInfo": { "ip": "186.228.23.255" } },
  "source": {
    "currency": "USDT"
  },
  "destination": {
    "currency": "EUR",
    "amount": 50,
    "paymentType": "bank_transfer",
    "paymentCode": "sepa_bank",
    "additionalPaymentDetails": { "iban": "BE71096123456769", "bic": "GKCCBEBB", "street": "…", "city": "…", "postalCode": "…" }
  }
}

Onramp

{
  "email": "[email protected]",
  "amount": 100,
  "currency": "EUR",
  "paymentType": "bank_transfer",
  "purposeCode": "expense_or_medical_reimbursement",
  "redirectUrl": "https://www.example.com/return",
  "withdrawDetails": { "cryptoTicker": "USDC", "walletAddress": "0xcf28…d2d2" }
}
{
  "userId": "UX-2123123123123",
  "orderType": "onramp",
  "purposeCode": "expense_or_medical_reimbursement",
  "successRedirectUrl": "https://www.example.com/success",
  "failureRedirectUrl": "https://www.example.com/failure",
  "source": { "currency": "EUR", "amount": 100, "paymentType": "bank_transfer" },
  "destination": {
    "currency": "USDC",
    "walletAddress": "0xcf28…d2d2",
    "additionalPaymentDetails": { "walletOwner": "self", "userConfirmed": true }
  }
}

Offramp

{
  "email": "[email protected]",
  "amount": 10,
  "currency": "PHP",
  "paymentCode": "gcash",
  "additionalDetails": { "phone": "1234567890" },
  "depositDetails": { "cryptoTicker": "USDC", "sendersWalletAddress": "0xcf28…d2d2" },
  "purposeCode": "fee_payments"
}
{
  "userId": "UX-2123123123123",
  "orderType": "offramp",
  "purposeCode": "fee_payments",
  "source": { "currency": "USDC", "sendersWalletAddress": "0xcf28…d2d2" },
  "destination": {
    "currency": "PHP",
    "amount": 10,
    "paymentType": "local_wallet",
    "paymentCode": "gcash",
    "additionalPaymentDetails": { "phone": "1234567890" }
  }
}

Crypto payin

{ "email": "[email protected]", "amount": 100, "cryptoTicker": "USDTPOLYGON", "purposeCode": "payroll_processing" }
{
  "userId": "UX-2123123123123",
  "orderType": "payin",
  "purposeCode": "payroll_processing",
  "source": { "currency": "USDTPOLYGON", "amount": 100 },
  "destination": { "currency": "USDT" }
}

Crypto payout

{
  "email": "[email protected]",
  "amount": 10000,
  "cryptoTicker": "USDC",
  "prefundCurrency": "USDT",
  "walletAddress": "0xc0ff…4345",
  "purposeCode": "expense_or_medical_reimbursement",
  "partnerId": "test-id"
}
{
  "userId": "UX-2123123123123",
  "orderType": "payout",
  "purposeCode": "expense_or_medical_reimbursement",
  "partnerId": "test-id",
  "source": { "currency": "USDT" },
  "destination": {
    "currency": "USDC",
    "amount": 10000,
    "walletAddress": "0xc0ff…4345",
    "additionalPaymentDetails": { "walletOwner": "exchange", "exchangeName": "binance" }
  }
}

Crypto prefund

{ "cryptoTicker": "USDC", "amount": 20000, "sendersWalletAddress": "0x1231…CDEF", "balanceCurrency": "USDT" }
{
  "orderType": "crypto_prefund",
  "purposeCode": "company_expenses",
  "source": { "currency": "USDC", "amount": 20000, "sendersWalletAddress": "0x1231…CDEF" },
  "destination": { "currency": "USDT" }
}

Reading an order

GET /v3/orders/{orderId} returns the same order, with the deposit and withdraw sides renamed to
match the request. The path accepts an OR- or PF- id, or your own partnerId.

v2 fieldv3 field
data.orderIddata.id
data.typedata.type
data.statusdata.status
data.depositCurrency, data.depositAmountdata.source.currency, data.source.amount
data.withdrawCurrency, data.withdrawAmountdata.destination.currency, data.destination.amount
data.senderName, data.recipientNamedata.senderName, data.recipientName
data.feesdata.fees (same structure)
data.partnerContextdata.partnerContext
data.transactionHashdata.txnHash
data.partnerIdNot returned. Use it as the path parameter instead
data.exchangeRateNot returned. The applied rate is in quoteHistory on the create response and on webhooks
—data.purposeCode, data.purposeCodeReason, data.failureCode, data.failureMessage

Amounts in v3 read responses are strings ("amount": "171729"), not numbers.

GET /v3/orders adds status, orderType, fromDate, toDate, userId and currency
filters, each accepting comma-separated values where it makes sense. The list is under
data.transfers, with the pagination object alongside it inside data, and each item uses
orderType where the single read uses type.

Order statuses

No status has been renamed. v3 adds the following values, so extend any switch you have:

New statusWhen
fund_deposited, fund_deposit_failedThe fiat leg of an onramp or swap has been received or has failed
asset_processingA crypto leg is being processed
manual_reviewThe order is held for a compliance review
approval_pendingA payout is waiting for approval on accounts with payout approval enabled
cancelledThe order was cancelled through POST /v3/orders/cancel
expiredThe payment window closed

The full v2 set (initiated, fund_processing, fund_settled, fund_failed,
asset_deposited, asset_deposit_failed, asset_settled, asset_settle_failed,
rejected, completed) is unchanged.

Idempotency

partnerId keeps its role, with three differences:

  • The check now covers every order type, including fiat and crypto prefunds, which v2 did not
    check.
  • A reused partnerId returns 400 VALIDATION_ERROR with PARTNER_ID_ALREADY_USED in
    error.context.details[].code. v2 returned 409 DUPLICATE_REQUEST.
  • To recover the original order after a timeout, call GET /v3/orders/{partnerId}.

Cancelling an order

New in v3: POST /v3/orders/cancel with { "orderId" } cancels an offramp order that has not yet
received its crypto deposit.

Balance

GET /v3/balance returns the same per-currency figures, wrapped:

{
  "status": "success",
  "data": {
    "balance": [ { "currency": "USD", "totalCollectionsAmount": 15000.5, "totalPayoutAmount": 8500.25, "totalPayoutInTransitBalance": 1200, "totalSettledAmount": 12000.75, "totalUnsettledAmount": 2500.25, "totalAvailablePrefundingBalance": 5000, "totalPayoutFee": 150.5 } ],
    "date": "2026-03-06"
  },
  "pagination": { ... }
}

The per-currency fields are the same, including totalAvailablePrefundingBalance and the
date the balance was computed for. page and limit are new, and date (YYYY-MM-DD) is now
documented as a query parameter for a historical balance.

Prefunding

Fiat and crypto prefunds are orders. POST /v3/orders with orderType: fiat_prefund returns the
bank details to pay into under data.paymentsData (v2: paymentDetails), and
orderType: crypto_prefund returns data.walletAddress. Both return a PF- id as data.orderId.

The deprecated v2 POST /v2/prefunds/crypto-prefund, which returned a standing wallet address
without creating an order, is replaced by POST /v3/prefund-accounts/crypto-wallet.

Sandbox testing

v3 has explicit sandbox
simulation endpoints that move an object to a target status and fire the matching webhook:

EndpointPurpose
POST /v3/simulation/orderMove an order or prefund to a status (fund_settled, fund_failed, manual_review, expired, …), optionally with an errorCode
POST /v3/simulation/userMove a user to a KYC or KYB status
POST /v3/simulation/webhook-resendRe-send a webhook
POST /v3/simulation/create-prefundCredit a sandbox balance (replaces POST /v2/sandbox/prefunds/create-prefund)
POST /v3/simulation/qr-codeGenerate a QR string for QR payout testing

See Order status simulation.

Webhooks

Delivery is unchanged: the same URL and secret from the dashboard, the same X-Transfi-Hmac-Hash
HMAC-SHA256 signature over the raw body, the same retry schedule, and any 2xx acknowledges.
The event pages have been rewritten for v3 and are the reference for your handler:
Webhook events.

What the v3 documentation shows differently from the v2 documentation:

  • Top level. Order events carry mid and createdAt. orgId and partnerContext no longer
    appear on order events.
  • order block. Adds quoteHistory (the quote applied, with quoteId), bankTxnId,
    withdrawCurrency and withdrawAmount. Crypto payin and payout samples use
    depositCurrency / depositAmount / withdrawCurrency / withdrawAmount rather than
    fiatTicker / cryptoTicker.
  • order.type. Still type, not orderType, with values payin, payout, buy, sell,
    gaming, plus new fiat_prefund, crypto_prefund and swap. Note that webhooks say buy
    and sell where the REST API says onramp and offramp.
  • Statuses. Onramp uses fund_deposited / fund_deposit_failed where v2 documented
    fund_settled / fund_failed for the fiat leg. Payout events drop fund_returned and add the
    crypto payout statuses asset_deposited and fund_deposit_failed. Offramp drops
    asset_deposit_failed. Payin-with-wallet drops asset_deposited.
  • User events. Add kyc_pending, kyc_failed, kyc_manual_review, kyc_blocked,
    kyc_expired and the matching kyb_* statuses, and carry mid.
  • Prefunds now send webhooks (entityType: prefund). v2 prefunds did not. There are new
    event pages for prefunds and swaps.

The separate "Webhook errors" pages are gone. Their codes are in the Webhook Errors table of the
error codes page.

Error codes

The v3 error codes page is organised by area
and adds an envelope column that tells you whether a code arrives in error.code, in
error.context.details[].code, in a success payload, or only in webhooks.

Codes you will no longer receive: ADDRESS_RESUBMISSION_REQUIRED, BIOMETRICS_CHECK_FAILED,
CONTACT_NOT_VERIFIED (now EMAIL_NOT_VERIFIED / PHONE_NOT_VERIFIED), INVALID_PAYMENT_CODE,
INVALID_QR_AMOUNT, INVALID_QR_DETAILS, INVALID_QR_STRING, QR_DETAILS_FETCH_FAILED,
SYSTEM_ERROR, KYC_ALREADY_PERFORMED (now KYC_ALREADY_SUBMITTED), KYB_UNDER_REVIEW.

Codes that are new in v3 fall into a few families. Order validation is far more specific:
USER_ID_REQUIRED, SOURCE_AMOUNT_REQUIRED, SOURCE_PAYMENT_CODE_REQUIRED,
DESTINATION_PAYMENT_TYPE_REQUIRED, PAYMENT_METHOD_NOT_FOUND, WALLET_ADDRESS_INVALID,
SENDERS_WALLET_ADDRESS_INVALID, INVALID_ORDER_TYPE, SUCCESS_REDIRECT_URL_REQUIRED,
FAILURE_REDIRECT_URL_REQUIRED, DEVICE_DETAILS_REQUIRED, QUOTE_INSUFFICIENT_ORDER_AMOUNT.
User and KYC checks are named: USER_ALREADY_EXISTS, USER_ORGANIZATION_MISMATCH,
USER_TYPE_MISMATCH, USER_VERSION_MISMATCH, BASIC_KYC_NOT_APPROVED,
STANDARD_KYC_NOT_APPROVED, KYC_ALREADY_SUBMITTED, COUNTRY_NOT_SUPPORTED,
INVALID_ID_DOC_TYPE. Compliance adds COMPLIANCE_CHECK_FAILED,
COMPLIANCE_CHECK_UNDER_MANUAL_REVIEW, KYT_CHECK_FAILED and EMAIL_NOT_ALLOWED (see the
compliance error codes changelog).
Invoices add INVALID_FILE_TYPE, FILE_TOO_LARGE, INVOICE_NOT_FOUND,
INVOICE_USER_ID_MISMATCH. Settlement accounts and IBANs bring their own set.

The generic VALIDATION_ERROR wraps all of these at the top level.


Migration checklist

  1. Store userId. Look up the userId for every email you reference and persist it. Every v3 KYC, KYB, invoice and order call needs it. Create new users through the sender and recipient endpoints, not POST /v3/users/*.
  2. Switch read endpoints first. GET /v3/orders/{orderId}, GET /v3/balance, GET /v3/users/*, GET /v3/kyc/status/USERID are safe to move before anything else. Update your parsers for the data envelope and for longer order ids.
  3. Update your error handling to read error.code, then error.context.details[].code or error.details[].code.
  4. Register settlement accounts if you used per-order withdrawDetails, and confirm your IP allowlist covers prefunds and QR lookups.
  5. Move order creation one orderType at a time, in sandbox, using POST /v3/simulation/order to drive each order to every status you handle, including the new ones.
  6. Move KYC and KYB calls and read the hosted URL from data.kycUrl / data.kybUrl. Pass uiEnabled: true on standard KYB.
  7. Replace the rate calls with GET /v3/exchange-rates and the new field names.
  8. Re-check your webhook handler against the v3 event pages.
  9. Retire the v2 calls before 1 December 2026.

Support

Reply to this announcement or write to [email protected] with
your traceId or errorId and we will help.