Skip to main content

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​

StatusMeaning
PROCESSINGThe order exists and the payout is in flight. Not terminal.
SUCCESSThe recipient has been paid. Terminal.
FAILEDThe 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.

JavaScript
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)​

  1. Decode the QR your user scanned. No token needed.
  2. Check supported. false means we read the code correctly but do not serve its market yet, so let your user know at this step rather than at checkout. true means the market is served; limits, payer KYC and the amount are still checked when you create the order.
  3. Read payeeAddressing to know what the code actually points at:
    • ACCOUNT_IDENTIFIER — the code carries a real bank account. bankCode and accountNumber are filled; send them when you create the order.
    • PROXY or SCHEME_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 are null and you omit them. accountName is the recipient's identity — show that to your user instead of an account number.
  4. Store qrToken as-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 with INVALID_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:

CurrencyMaximum decimal placesExample
VND0100000
IDR0150000
PHP2500.25
ARS21334.85
BRL2100.99
PEN250.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.