Scan-to-Pay & Cashout
An order is a fiat payout you create. It debits your crypto fund and pays a recipient in local currency. Scan-to-Pay and Cashout create different orders, but they behave identically: same lifecycle, same idempotency rule, same way of tracking.
The sequence
quote → review (optional) → create → poll or webhook
Quote — scan-to-pay ·
cashout. Previews the crypto cost and the fee, and
returns the minAmount / maxAmount this payout accepts. A quote reserves nothing unless you
lock its rate.
Review — scan-to-pay · cashout. Runs every eligibility check that creation runs — service availability, KYC, currency support, amount bounds, risk screening, spending limits, fund balance — without moving any money. Use it to show the user a clear error before you commit. It is optional and quick to call.
Create — scan-to-pay ·
cashout. Debits your fund and starts the payout. The
response comes back immediately with status PROCESSING; the payout itself is asynchronous.
Track — scan-to-pay · cashout, or a webhook.
Status
| Status | Meaning |
|---|---|
PROCESSING | The order exists and the payout is in flight. Not terminal. |
SUCCESS | The recipient has been paid. Terminal. |
FAILED | The payout did not happen. Terminal, and your fund is refunded. |
Only SUCCESS and FAILED are terminal, and only terminal states fire a webhook.
Locking the rate
Send lock: true with a quote to hold its rate and fees for five minutes. The quote then returns a
quoteId and its expiresAt. Review and create the order with that quoteId and the same currency
and amount, and the order is priced exactly as quoted. The order returns the locked rate under
quote.
A locked quote holds the price, not the funds. Creating the order still checks your fund balance and
every other condition at that moment. Review the order with the quoteId to see the result first.
A locked quote is good for one order. When it has been used, has expired, or does not match the
order, the call fails with a QUOTE_* code, and you request
a new locked quote. A quote stays used even if the order created from it later fails.
orderId is your idempotency key
You choose the orderId when you create the order. It must be unique across your partner account,
and reusing one is rejected with DUPLICATE_TRANSACTION_ID (HTTP 409).
This makes retries safe. If a create request times out and you are unsure whether the order was
created, retry it with the same orderId:
- The retry succeeds → the first attempt never landed, and you now have exactly one order.
- The retry returns
DUPLICATE_TRANSACTION_ID→ the first attempt did land. Fetch the order by its id and carry on.
Either way, a retry with the same orderId results in exactly one order.
async function createOrder(order) {
const {code, data} = await post('/v3/scan-to-pay/orders', order);
if (code === 'SUCCESS') return data;
if (code === 'DUPLICATE_TRANSACTION_ID') return getOrder(order.orderId);
throw new Error(code);
}
Tracking to completion
Register a webhook and you are told when an order reaches a terminal state. Otherwise poll the order endpoint.
The order endpoint is the source of truth in both cases. Each callback is delivered once, so keep
polling as the fallback: fetch any order still PROCESSING past its expected settlement time
rather than assuming it is pending.
Paying a merchant QR (Scan-to-Pay)
- Decode the QR your user scanned. No token needed.
- Check
supported.falsemeans we read the code correctly but do not serve its market yet, so let your user know at this step rather than at checkout.truemeans the market is served; limits, payer KYC and the amount are still checked when you create the order. - Read
payeeAddressingto know what the code actually points at:ACCOUNT_IDENTIFIER— the code carries a real bank account.bankCodeandaccountNumberare filled; send them when you create the order.PROXYorSCHEME_TOKEN— the code carries a stand-in (an email, a phone number, a tax id) or an identifier only the local scheme can resolve. These codes carry no bank account, so the bank fields arenulland you omit them.accountNameis the recipient's identity — show that to your user instead of an account number.
- Store
qrTokenas-is and pass it back verbatim when you create the order. It is signed and short-lived, so keep it unchanged. Omitting it where the payout route needs it rejects the order withINVALID_PAYMENT_INFO.
fiatCurrency is null exactly when supported is false — a market we do not serve has no currency
to quote. A plain bank transfer typed in by hand, with no QR at all, needs none of these fields.
In BRL, ARS and PEN, a user paying in the currency of their own nationality also needs an address and a gender in their KYC profile. See Markets that need extra profile fields.
A note on fiatAmount
fiatAmount is what the recipient receives. The fee is added on top and shown in the quote, and
the crypto debited from your fund covers both.
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 becomes 12.34,
and VND 100000.9 becomes 100000. Quotes and orders return the cut amount. Send no more decimal
places than the currency allows, so that the amount paid out is the amount you showed your user.