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
| Milestone | Date |
|---|---|
| v3 available in sandbox and production | Available 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.comandhttps://api.transfi.com. - The
MIDheader. Send it on every request, as today. The rules for it have not changed. - Identifiers.
userId(UX-…),orderId(OR-…), prefund ids (PF-…) andinvoiceId(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-Hashheader, retried on the same schedule, acknowledged with any2xx. See Webhooks for payload changes. - Purpose codes. The same
purposeCodevalues, andpurposeCodeReasonis still mandatory whenpurposeCodeisother.
The five changes that affect every integration
1. Users are referenced by userId, not email
userId, not emailIn 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-ratesand
GET /v3/config/transaction-limits(a missing or mistyped field, an unknownorderType) put
the array directly aterror.details[]rather thanerror.context.details[]. Read both. - Server-side failures are returned as
500witherror.code: INTERNAL_SERVER_ERRORand a
generic message. Retry with backoff and quote theerrorIdto 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
| v2 | v3 | Notes |
|---|---|---|
| Configuration | ||
GET /v2/supported-currencies | GET /v3/config/supported-currencies | Same parameters; userType added; response gains pagination |
GET /v2/payment-methods | GET /v3/config/payment-methods | currency now optional; response key paymentMethods → data |
GET /v2/config/list-tokens | GET /v3/config/list-tokens | Adds contractAddress; response gains pagination |
GET /v2/config/transaction-limits (undocumented) | GET /v3/config/transaction-limits | orderType values payin / payout → deposit / withdraw |
| — | GET /v3/config/list-mids | New |
| — | GET /v3/config/address/autocomplete | New |
| Exchange rates | ||
GET /v2/exchange-rates/deposit | GET /v3/exchange-rates?orderType=payin | |
GET /v2/exchange-rates/withdraw | GET /v3/exchange-rates?orderType=payout | |
GET /v2/exchange-rates/fiat-to-crypto | GET /v3/exchange-rates?orderType=onramp | |
GET /v2/exchange-rates/crypto-to-fiat | GET /v3/exchange-rates?orderType=offramp | |
GET /v2/exchange-rates/gaming | GET /v3/exchange-rates?orderType=gaming | |
GET /v2/exchange-rates/live-rates | No equivalent | Use the flow-specific quote instead |
| Users | ||
POST /v2/users/individual | POST /v3/senders/individual or POST /v3/recipients/individual | By the role the user plays; call mandatory-fields first. |
GET /v2/users/individuals | GET /v3/users/individual | Path is singular; response key users → data |
POST /v2/users/business | POST /v3/senders/business or POST /v3/recipients/business | By the role the user plays; call mandatory-fields first. |
GET /v2/users/business | GET /v3/users/business | Response key users → data |
| — | POST /v3/senders/mandatory-fields, POST /v3/recipients/mandatory-fields | New: 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/individual | New: correct name or date of birth |
| — | GET /v3/users/account-type | New |
| Verification | ||
POST /v2/verification/request-link | POST /v3/verification/request-link | Same body; rate limited (3 per 10 minutes per user, 429) |
| KYC | ||
POST /v2/kyc/standard | POST /v3/kyc/standard | email → userId; response redirectUrl → data.kycUrl |
POST /v2/kyc/advanced | POST /v3/kyc/advanced | email → userId; response redirectUrl → data.kycUrl |
POST /v2/kyc/share/same-vendor | POST /v3/kyc/share/same-vendor | email, name → userId |
POST /v2/kyc/share/third-vendor | POST /v3/kyc/share/third-vendor | email, firstName, lastName → userId; idDocType values upper case |
POST /v2/kyc/share/add-docs | POST /v3/kyc/share/add-docs | email → userId |
GET /v2/kyc/user?email= | GET /v3/kyc/status/USERID | Path parameter instead of email query |
| — | GET /v3/kyc/account-status | New: per-role, per-tier status |
| KYB | ||
POST /v2/kyb/standard | POST /v3/kyb/standard | email → userId; response link → data.kybUrl |
POST /v2/kyb/advanced | POST /v3/kyb/advanced | email → userId; response link → data.kybUrl |
GET /v2/kyb/user?email= | GET /v3/kyb/status/USERID | Path parameter instead of email query |
| — | GET /v3/kyb/account-status | New |
| Invoices | ||
POST /v2/invoices/create | POST /v3/invoices/create | email → userId |
| Orders | ||
POST /v2/orders/deposit | POST /v3/orders with orderType: payin | |
POST /v2/orders/gaming | POST /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/payin | POST /v3/orders with orderType: payin and a crypto source.currency | |
POST /v2/crypto/payout | POST /v3/orders with orderType: payout and a crypto destination.currency | |
POST /v2/qr-payout/order | POST /v3/orders with orderType: payout and destination.qrCode | |
POST /v2/qr-payout/verify-qrcode | GET /v3/config/qr-code-details?qrCode=…¤cy=… | POST body → GET query; response qrCodeDetails → data, taxId dropped |
GET /v2/orders/{orderId} | GET /v3/orders/{orderId} | Fields renamed, see below |
GET /v2/orders | GET /v3/orders | New filters; response orders → data.transfers |
| — | POST /v3/orders/cancel | New: cancel an offramp order before the crypto deposit |
| — | POST /v3/orders with orderType: swap | New |
| Prefunding | ||
POST /v2/prefunds/create-fiat-prefund | POST /v3/orders with orderType: fiat_prefund | |
POST /v2/prefunds/create-crypto-prefund | POST /v3/orders with orderType: crypto_prefund | |
| — | POST /v3/prefund-accounts/crypto-wallet | Returns the standing deposit wallet for a ticker |
POST /v2/sandbox/prefunds/create-prefund | POST /v3/simulation/create-prefund | Sandbox only |
| Balance | ||
GET /v2/balance | GET /v3/balance | Response 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 response | v3 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 parameter | v3 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 |
amount | amount |
direction (forward / reverse) | direction (forward / reverse), same meaning |
paymentCode | paymentCode, 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 field | v3 field |
|---|---|
data.fiatAmount, message.data.sentAmount, message.data.depositAmount | sourceAmount |
data.withdrawAmount, data.depositAmount, message.data.receiveAmount | destinationAmount |
data.exchangeRate, message.data.cryptoPrice | conversionRate |
data.fees.processingFee, message.data.processingFee, processingFeeDetails.transfiFee | processingFee |
data.fees.partnerFee, processingFeeDetails.customerFee | customerFee |
data.fees.totalFee, message.data.totalFee | totalFee |
message.data.networkFee | networkFee |
message.data.minLimit, message.data.maxLimit | minLimit, 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.
| v2 | v3 |
|---|---|
POST /v2/users/individual | POST /v3/senders/individual or POST /v3/recipients/individual |
POST /v2/users/business | POST /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 field | v3 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 |
date | dob, same DD-MM-YYYY format |
country | country, plus currencyType (fiat or crypto) |
businessName, regNo | Same 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/individualandPOST /v3/users/businessstill exist in v3 and
accept the v2 body (withphoneCoderequired alongsidephone). 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
| v2 | v3 |
|---|---|
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.
| Endpoint | v2 request | v3 request | v2 response | v3 response |
|---|---|---|---|---|
POST /kyc/standard | email, redirectUrl | userId, redirectUrl | { "redirectUrl", "userId" } | data.kycUrl, data.userId |
POST /kyc/advanced | email, redirectUrl | userId, redirectUrl | { "redirectUrl", "userId" } | data.kycUrl, data.userId |
POST /kyc/share/same-vendor | email, name, token | userId, token | { "email", "status", "userId", "userName" } | data.userId, data.kycStatus, data.code |
POST /kyc/share/third-vendor | multipart with email, firstName, lastName, userId, … | multipart with userId, … (no email, firstName, lastName) | { "status" } | data.userId, data.kycStatus |
POST /kyc/share/add-docs | email, additionalDoc, additionalDocType | userId, additionalDoc, additionalDocType | { "status" } | data.userId, data.kycStatus |
GET /kyc/user?email= | — | GET /kyc/status/USERID | { "status", "rejectLabels" } | { "status", "rejectLabels" } (unchanged) |
POST /kyb/standard | email | userId, optional uiEnabled | { "link", "expiresIn" } | data.kybUrl, data.expiresIn, data.userId |
POST /kyb/advanced | email | userId, 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:
| Situation | v2 | v3 |
|---|---|---|
| Unknown user on a status or KYC/KYB call | 404 USER_NOT_FOUND | 400 VALIDATION_ERROR with USER_NOT_FOUND in error.context.details[].code |
| KYC already submitted (standard, advanced, share) | 200 with code: KYC_ALREADY_PERFORMED | 400 VALIDATION_ERROR with KYC_ALREADY_SUBMITTED in error.context.details[].code |
| Standard KYC prerequisite not met | 400 BASIC_SCREENING_MISSING | 400 with BASIC_KYC_NOT_APPROVED, BASIC_SCREENING_IN_PROGRESS or BASIC_SCREENING_REJECTED |
| Additional docs before standard KYC | 400 NOT_ELIGIBLE | 400 with STANDARD_KYC_NOT_APPROVED |
| User belongs to another organisation | 401 UNAUTHORIZED_USER | 400 USER_ORGANIZATION_MISMATCH |
| KYB initiation while a review is pending | 400 KYB_UNDER_REVIEW | No 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
orderType| You used to call | Now send orderType | source holds | destination holds |
|---|---|---|---|
POST /v2/orders/deposit | payin | fiat currency, amount, payment method | holding currency (USDT, EUR, …) |
POST /v2/orders/deposit + withdrawDetails.walletAddress | onramp | fiat currency, amount, payment method | crypto ticker and walletAddress |
POST /v2/orders/gaming | gaming | fiat currency, amount, payment method | the holding ticker configured for your account (filled in if omitted) |
POST /v2/payout/orders | payout | balance currency to debit | fiat currency, amount, payment method, bank or wallet details |
POST /v2/payout/orders + depositDetails | offramp | crypto ticker (and sender wallet) | fiat currency, amount, payment method, bank or wallet details |
POST /v2/crypto/payin | payin | crypto ticker, amount | holding currency |
POST /v2/crypto/payout | payout | balance currency to debit | crypto ticker, amount, walletAddress |
POST /v2/qr-payout/order | payout | balance currency to debit | fiat currency, amount, qrCode |
POST /v2/prefunds/create-fiat-prefund | fiat_prefund | fiat currency, amount, payment method | balance currency |
POST /v2/prefunds/create-crypto-prefund | crypto_prefund | crypto ticker, amount | balance 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 field | v3 field | Notes |
|---|---|---|
email | userId | Required for payin, payout, onramp, offramp, gaming, swap; omit for prefunds and QR payouts |
amount + currency | source.amount + source.currency (collecting) or destination.amount + destination.currency (paying out) | Send exactly one of source.amount and destination.amount |
paymentType, paymentCode | source.paymentType, source.paymentCode (collecting) or destination.paymentType, destination.paymentCode (paying out) | On fiat payouts destination.paymentType is now required alongside paymentCode |
additionalDetails | source.additionalPaymentDetails or destination.additionalPaymentDetails | Same dynamic fields, from the payment methods API |
cryptoTicker | source.currency or destination.currency | |
walletAddress (crypto payout) | destination.walletAddress | Plus 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.currency | Now required: it names the balance to debit |
balanceCurrency (prefunds) | destination.currency | |
redirectUrl | successRedirectUrl + failureRedirectUrl | Both must be https://; optional if a default is configured on your account |
partnerContext | customerMetaData | A root-level partnerContext is silently ignored. The data is still returned as partnerContext when you read the order |
partnerId | partnerId | Unchanged, still your idempotency key. See Idempotency |
sourceUrl | sourceUrl | Unchanged, must include https:// |
invoiceId | invoiceId | Unchanged; the invoice must belong to the userId on the order |
headlessMode | headlessMode | Same name, but see the behaviour note below |
deviceDetails | deviceDetails | Same shape; ipInfo.countryCode3 added. Send it on every order, not only headless ones |
purposeCode, purposeCodeReason | purposeCode, purposeCodeReason | purposeCode is now required for every order type, including prefunds. other always needs a purposeCodeReason |
| — | customization.locale, customization.paymentDescription | New |
| — | settlementAccountId | New |
Response mapping
| v2 response | v3 response |
|---|---|
orderId | data.orderId |
paymentUrl | data.payUrl or data.paymentUrl |
redirectUrl | Not echoed back |
partnerContext | Not 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
paymentCodeand the fulladditionalDetails. v3 only does so if that is configured on your
account. SendheadlessMode: truewhen you render your own payment page. Headless payins need
source.paymentCodeandsource.paymentType.gamingorders 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 returned400with apaymentUrlto an error screen.
v3 returns200withdata.payUrlpointing at a/screening-failedpage. If you keyed off the
400, inspect the URL instead. - Settlement is account-level. Per-order
withdrawDetailsanddepositDetailsare gone.
Register where you want to be settled withPOST /v3/settlement-accountand 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 field | v3 field |
|---|---|
data.orderId | data.id |
data.type | data.type |
data.status | data.status |
data.depositCurrency, data.depositAmount | data.source.currency, data.source.amount |
data.withdrawCurrency, data.withdrawAmount | data.destination.currency, data.destination.amount |
data.senderName, data.recipientName | data.senderName, data.recipientName |
data.fees | data.fees (same structure) |
data.partnerContext | data.partnerContext |
data.transactionHash | data.txnHash |
data.partnerId | Not returned. Use it as the path parameter instead |
data.exchangeRate | Not 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 status | When |
|---|---|
fund_deposited, fund_deposit_failed | The fiat leg of an onramp or swap has been received or has failed |
asset_processing | A crypto leg is being processed |
manual_review | The order is held for a compliance review |
approval_pending | A payout is waiting for approval on accounts with payout approval enabled |
cancelled | The order was cancelled through POST /v3/orders/cancel |
expired | The 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
partnerIdreturns400 VALIDATION_ERRORwithPARTNER_ID_ALREADY_USEDin
error.context.details[].code. v2 returned409 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:
| Endpoint | Purpose |
|---|---|
POST /v3/simulation/order | Move an order or prefund to a status (fund_settled, fund_failed, manual_review, expired, …), optionally with an errorCode |
POST /v3/simulation/user | Move a user to a KYC or KYB status |
POST /v3/simulation/webhook-resend | Re-send a webhook |
POST /v3/simulation/create-prefund | Credit a sandbox balance (replaces POST /v2/sandbox/prefunds/create-prefund) |
POST /v3/simulation/qr-code | Generate a QR string for QR payout testing |
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
midandcreatedAt.orgIdandpartnerContextno longer
appear on order events. orderblock. AddsquoteHistory(the quote applied, withquoteId),bankTxnId,
withdrawCurrencyandwithdrawAmount. Crypto payin and payout samples use
depositCurrency/depositAmount/withdrawCurrency/withdrawAmountrather than
fiatTicker/cryptoTicker.order.type. Stilltype, notorderType, with valuespayin,payout,buy,sell,
gaming, plus newfiat_prefund,crypto_prefundandswap. Note that webhooks saybuy
andsellwhere the REST API saysonrampandofframp.- Statuses. Onramp uses
fund_deposited/fund_deposit_failedwhere v2 documented
fund_settled/fund_failedfor the fiat leg. Payout events dropfund_returnedand add the
crypto payout statusesasset_depositedandfund_deposit_failed. Offramp drops
asset_deposit_failed. Payin-with-wallet dropsasset_deposited. - User events. Add
kyc_pending,kyc_failed,kyc_manual_review,kyc_blocked,
kyc_expiredand the matchingkyb_*statuses, and carrymid. - 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
- Store
userId. Look up theuserIdfor 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, notPOST /v3/users/*. - Switch read endpoints first.
GET /v3/orders/{orderId},GET /v3/balance,GET /v3/users/*,GET /v3/kyc/status/USERIDare safe to move before anything else. Update your parsers for thedataenvelope and for longer order ids. - Update your error handling to read
error.code, thenerror.context.details[].codeorerror.details[].code. - Register settlement accounts if you used per-order
withdrawDetails, and confirm your IP allowlist covers prefunds and QR lookups. - Move order creation one
orderTypeat a time, in sandbox, usingPOST /v3/simulation/orderto drive each order to every status you handle, including the new ones. - Move KYC and KYB calls and read the hosted URL from
data.kycUrl/data.kybUrl. PassuiEnabled: trueon standard KYB. - Replace the rate calls with
GET /v3/exchange-ratesand the new field names. - Re-check your webhook handler against the v3 event pages.
- 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.