Scan-to-Pay
Your user scans a merchant's QR code. You decode it, quote what it will cost in crypto, and create an order. We then debit your fund and pays the merchant in local currency.
The flow:
- Decode the QR —
get-qr-code-info. Public, no signature. - Quote it —
review-sell-order. - Create the order —
create-sell-order, passing the decoded QR data through inextendInfo. - Track it —
orders/details, plus a webhook.
The user must be KYC-verified first. See KYC.
The order object
create-sell-order, orders/details and the webhook all return the
same object, and so does cashout.
| Field | Type | Notes |
|---|---|---|
externalOrderId | string | Your id for the order. |
type | string | SELL for scan-to-pay, not SCAN_TO_PAY. CASHOUT for a cashout. |
fiatAmount | number | The fee-inclusive total you are charged, not the amount the merchant receives. |
paidAmount | number | |
status | string | See below. |
descriptions | string | Plural. Success, or the failure reason. |
createdAt | string | 2026-07-14 10:32:07 — UTC+7, no zone marker. |
expiresAt | string | Created + 15 minutes, same format. |
tokenTransfer | object | currency, network, price, amount, address, txHash, memo. |
bankTransfer | object | bankAccountName, bankAccountNumber, bankName, bankCode, contentPayment, totalPayment, qrUrl. |
fees | object | systemFee, processingFee. |
signature | string | Over externalOrderId|type|fiatAmount|status|secretKey. |
bankTransfer also carries an expiresAt of its own, which arrives empty; read expiry from the
order-level expiresAt.
Status
An order starts at AWAITING_PAYMENT and settles at SUCCESS or ERROR — the two terminal
states, and the two that fire the webhook. The states between mark
the payout in flight.
| Status | Meaning |
|---|---|
AWAITING_PAYMENT | Created; the payout has not gone out yet. |
PAYMENT_COMPLETED | The recipient has been paid. |
PROCESSING | In flight. |
SUCCESS | Done. Terminal. |
ERROR | Failed. Terminal. Your fund has been refunded. |
Treat a status you do not recognise as still in progress.
Decode a QR code
GET /api/v2/public/get-qr-code-info?qrContent=<raw QR string>
Public: no signature, no partnerCode. Pass the raw string your scanner read.
Response data
| Field | Type | Notes |
|---|---|---|
bankCode, bankName | string | Feed bankCode straight into create-sell-order. |
bankAccountNumber | string | Likewise. |
recipientName | string | |
countryCode | string | VN, PH, TH, GE, BR, AR, PE. |
amount | number | Present only if the QR fixes an amount. |
content | string | |
qrType | string | vietqr, ph.ppmi.p2m, com.p2pqrpay, thailand.pwc, qris, georgia.customer.k, PIX, QR3. |
additionalData | object | Opaque. Its shape depends on the QR flavour. Pass it through verbatim rather than parsing it. |
The public endpoints return no response signature.
additionalData and recipientName are what create-sell-order wants in extendInfo. Keep them as
they arrive rather than rebuilding them.
Errors — 33 QR_CODE_NOT_SUPPORTED (unreadable or unsupported format), 20
SERVICE_TEMPORARILY_UNAVAILABLE (the service is temporarily unavailable; retry later), 1.
None of the shared signature errors apply.
Generate a test QR code
POST /api/v2/public/generate-philippines-qrcode
A sandbox helper that generates a Philippine QR code, so you have something to decode. It is public and needs no signature.
Request — amount (number, minimum 200). Response data — qrContent, the raw string you
feed back into get-qr-code-info.
Prices and limits
POST /api/v2/orders/prices
Current crypto prices, and the sell limits. The limits vary by currency and by payment provider, so read them here rather than hard-coding them.
Signed string
partnerCode|currencies|network|<secretKey>
fiatCurrency and service are not signed.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
partnerCode | string | Yes | |
currencies | string | Yes | Comma-separated: USDT,ETH,BTC. Signed verbatim, so the order matters. |
network | string | Yes | ERC20, TRC20, TON… |
fiatCurrency | string | No | Defaults to VND. |
service | string | No | Defaults to SELL, the value for scan-to-pay, so you can omit it. |
signature | string | Yes |
Response data
| Field | Type | Notes |
|---|---|---|
network | string | Echoed back. |
currencies | array | Per token: name, price, minSell, maxSell, minBuy, maxBuy. The four bounds are optional and may be absent. |
signature | string | Over network|secretKey. The token list itself is not signed. |
Prices are cached for up to 10 minutes, so quote with review-sell-order before
you commit to an amount.
Errors — 6 TOKEN_NOT_SUPPORTED_ON_NETWORK, 14 UNKNOWN_SERVICE_TYPE, 46
SERVICE_NOT_AVAILABLE, 53 FIAT_CURRENCY_NOT_SUPPORTED (the message names the currency), 1.
List supported banks
POST /api/v2/bank/get-supported-banks
Signed string
partnerCode|<secretKey>
Request — partnerCode (required), fiatCurrency (optional, defaults to VND), signature.
fiatCurrency is not part of the signed string.
Response data — banks, an array of bankCode / bankName / logo, and a signature over
secretKey alone. No other field goes into it, so the bank list is not covered by the signature.
A temporary interruption at the banking partner can answer success: true with an empty banks
array. Treat an empty list as a fetch to retry later rather than as the currency having no banks.
Errors — 1, plus the shared ones.
Verify a bank account
POST /api/v2/bank/account/verify
Resolves an account number to the name it is held under, so you can show the user who they are about to pay.
Signed string
partnerCode|bankCode|bankAccountNumber|<secretKey>
fiatCurrency is not signed.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
partnerCode | string | Yes | |
bankCode | string | Yes | |
bankAccountNumber | string | Yes | |
fiatCurrency | string | No | Defaults to VND. Only VND, NGN, GHS, ZAR, KES are supported — anything else returns 53. |
signature | string | Yes |
Response data — bankCode, bankAccountNumber, bankAccountName, and a signature over
bankCode\|bankAccountNumber\|bankAccountName\|secretKey.
Errors — 18 INVALID_BANK_ACCOUNT, 53 FIAT_CURRENCY_NOT_SUPPORTED, 56 BANK_NOT_SUPPORTED
(the bankCode is not in the list from get-supported-banks), 1.
Quote an order
POST /api/v2/orders/review-sell-order
What the order will cost in crypto, and the fees, before you commit. Nothing is created, and nothing is debited.
Signed string
partnerCode|currency|fiatAmount|<secretKey>
If you omit fiatAmount, the literal 0 takes its place in the string — sign …|0|, not …||.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
partnerCode | string | Yes | |
currency | string | Yes | The crypto you are selling — USDT. |
fiatAmount | number | No | The fiat you want the merchant to receive. No decimals for VND, up to two for PHP, ARS, BRL and PEN; see Amount precision. |
tokenAmount | number | No | Quote from the crypto side instead. Not signed. |
fiatCurrency | string | No | Defaults to VND. Not signed. |
userEmail | string | No | Not signed, unlike create-sell-order, where it is. |
signature | string | Yes |
Response data
| Field | Type | Notes |
|---|---|---|
fiatAmount | number | Echoed from your request — if you quoted by tokenAmount, this is undefined, and the response signature is computed over that literal text. |
currency | string | The crypto code. |
price | number | |
tokenAmount | number | The crypto that will be debited. |
totalPayment | number | |
processingFee, systemFee | number | |
signature | string | Over fiatAmount|currency|price|tokenAmount|totalPayment|secretKey. |
Amount precision
Maximum decimal places in fiatAmount, by currency:
| Currency | Maximum decimal places | Example |
|---|---|---|
VND | 0 | 100000 |
IDR | 0 | 150000 |
PHP | 2 | 500.25 |
ARS | 2 | 1334.85 |
BRL | 2 | 100.99 |
PEN | 2 | 50.55 |
Extra digits are cut off, not rounded, and the request goes through: PHP 12.345 is priced and
paid as 12.34. review-sell-order still echoes the fiatAmount you sent (12.345), while its
tokenAmount is priced on 12.34. Send no more decimal places than the currency allows, so that the
quoted fiatAmount is the amount paid. Cashout uses the same table.
Create an order
POST /api/v2/orders/create-sell-order
Creates the order, debits your fund, and pays the merchant.
Signed string
partnerCode|externalOrderId|currency|fiatAmount|bankCode|bankAccountNumber|content|userEmail|<secretKey>
An omitted fiatAmount becomes the literal 0 in that string. bankAccountNumber is in it too: if you
leave it out, the request is not rejected as missing a field, and its segment is signed as the
literal text undefined.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
partnerCode | string | Yes | |
externalOrderId | string | Yes | Your unique id. Also the idempotency key — a repeat returns 11. |
currency | string | Yes | The crypto to debit — USDT. |
userEmail | string | Yes | The end user. Must be KYC-verified. |
bankCode | string | Yes | From the QR decoding, or the bank list. |
content | string | Yes | Transfer remark. Unaccented letters, no special characters. |
bankAccountNumber | string | Yes | Omitting it is not rejected as missing, but it is signed — see above. |
fiatAmount | number | No | The fiat the merchant receives. No decimals for VND, up to two for PHP, ARS, BRL and PEN; see Amount precision. |
fiatCurrency | string | No | Defaults to VND. |
webhookSecretKey | string | No | Without it the order settles silently, read through orders/details. See Webhooks. |
extendInfo | object | per currency | The decoded QR data — see below. |
signature | string | Yes |
extendInfo
This is how the decoded QR data reaches the payment provider. It holds a qrType and one country key.
The country key carries recipientName and the opaque additionalData, both taken verbatim from
get-qr-code-info.
fiatCurrency | Key | Required? |
|---|---|---|
PHP | PH | recipientName is required, and is rejected when it is all digits and the account number is all digits too. |
GEL | GE | Required. Omit it and the request fails with 1 — see below. |
BRL | BR | Required in practice. |
ARS | AR | Required in practice. |
PEN | PE | Required in practice. |
VND, NGN, others | — | Send extendInfo: null. |
{
"partnerCode": "ACME",
"externalOrderId": "acme-1042",
"currency": "USDT",
"fiatCurrency": "PHP",
"fiatAmount": 500,
"bankCode": "UBPHPHMM",
"bankAccountNumber": "109645950075",
"content": "coffee",
"webhookSecretKey": "81663eae...",
"extendInfo": {
"qrType": "com.p2pqrpay",
"PH": {
"recipientName": "VIETNAMIT BISTRO",
"additionalData": { "tfrAcctNo": "109645950075", "uniqueId": "ph.ppmi.p2m" }
}
},
"signature": "I7XDfTngWIraq...=="
}
For those three currencies the country keys are read in the fixed order BR → AR → PE, and the
first one present wins, whichever currency you sent. An ARS order carrying a stray BR block pays
out against the BR data, so send exactly the one key that matches your fiatCurrency.
For GEL, omitting extendInfo.GE fails with 1 INTERNAL_SERVER_ERROR.
The ID, TH and VN keys are accepted but never read.
Response data — the order object, with status: "AWAITING_PAYMENT".
Errors — beyond the shared ones:
11 DUPLICATE_TRANSACTION_ID | externalOrderId is already used. Fetch the order instead. |
13 WEBHOOK_SECRET_NOT_FOUND | webhookSecretKey matches no registered endpoint. The order is not created. |
7 PARTNER_INSUFFICIENT_BALANCE | Your fund cannot cover it. |
24 / 25 | Below the minimum / above the maximum. |
36 / 37 / 42 | Per-transaction, daily, monthly cap. |
34, 59, 60, 61, 62, 64 | The user's KYC or nationality blocks the order. |
142 KYC_EXTRA_INFO_REQUIRED_FOR_LATAM_LOCAL_USER | The user's KYC profile lacks the address or gender this market requires; the message names them. See Markets that need extra profile fields. |
17 INVALID_TRANSFER_CONTENT | content has accents or special characters. |
18 INVALID_BANK_ACCOUNT | The account could not be verified, or recipientName is missing or invalid. |
44 INVALID_PAYMENT_INFO | A PHP QR order is missing its additionalData. |
56 BANK_NOT_SUPPORTED | bankCode is not in the provider's list. |
65 HIGH_RISK_BANK_ACCOUNT | The recipient is flagged as high risk. |
46 SERVICE_NOT_AVAILABLE | Scan-to-pay is off for you, or no provider covers this currency. |
12 TRANSACTION_CREATE_FAILED | The payout failed after the order was created. You are already refunded, and data holds the order. |
Get an order
POST /api/v2/orders/details
Returns the current state of an order. Use it to confirm a webhook and to reconcile your records.
Signed string
partnerCode|externalOrderId|<secretKey>
Request — partnerCode, externalOrderId, signature. All required.
Response data — the order object.
Errors — 15 TRANSACTION_NOT_FOUND (wrong id, or the order belongs to another partner), plus
the shared ones.