Quick start
An end-to-end scan-to-pay integration against the sandbox: authenticate, onboard a user, quote, pay,
and settle. Cashout is the same shape with a different path and a nested recipient object.
Before you start you need an RSA key pair (AliX holds the public half), your partner code, and your calling IPs on the allowlist.
1. Get an access token
The token names the user you act for, so you fetch one per customer. It lives 60 minutes and there
is no refresh token — cache it against expiresAt and re-run this when it nears expiry.
import jwt from 'jsonwebtoken';
import {randomUUID} from 'node:crypto';
import {readFileSync} from 'node:fs';
const BASE_URL = 'https://sandbox.alixpay.com';
const privateKey = readFileSync('alix-private.pem', 'utf8');
async function accessTokenFor(userEmail) {
const clientAssertion = jwt.sign(
{
iss: 'YOUR_PARTNER_CODE',
aud: 'alixpay-v3-token',
iat: Math.floor(Date.now() / 1000),
jti: randomUUID(),
},
privateKey,
{algorithm: 'RS256'},
);
const {data} = await call('POST', '/v3/auth/tokens', {clientAssertion, userEmail});
return data.accessToken;
}
Every call below goes through one helper, because every response is the same
envelope and you always branch on code:
async function call(method, path, body, token) {
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
...(token && {Authorization: `Bearer ${token}`}),
},
...(body && {body: JSON.stringify(body)}),
});
const envelope = await res.json();
if (envelope.code !== 'SUCCESS') {
throw Object.assign(new Error(`${envelope.code}: ${envelope.message}`), {code: envelope.code});
}
return envelope;
}
2. Verify the user
An unverified user cannot transact. Submit their KYC once, then poll until it settles.
const {data: kyc} = await call('GET', '/v3/kyc/status', null, token);
if (kyc.status === 'NONE') {
await call('POST', '/v3/kyc/submissions', {
firstName: 'Van A',
lastName: 'Nguyen',
nationality: 'VN',
documentType: 'ID_CARD',
dateOfBirth: '1994-03-18',
gender: 'MALE',
nationalId: '038094001234',
issueDate: '2021-06-02',
expiryDate: '2034-03-18',
frontImage: 'data:image/jpeg;base64,...', // data URLs, not links
backImage: 'data:image/jpeg;base64,...',
selfieImage: 'data:image/jpeg;base64,...',
reportDocument: 'data:application/pdf;base64,...', // from your KYC provider
}, token);
}
// Poll until VERIFIED. PROGRESSING still cannot transact.
See KYC for the statuses and what still blocks a verified user.
3. Decode the QR your user scanned
Skip this if you are paying a plain bank account — you would just supply bankCode and
accountNumber yourself. Decoding needs no token.
const {data: qr} = await call('POST', '/v3/scan-to-pay/qr-decodings', {qr: rawQrString});
// qr.bankCode, qr.accountNumber, qr.qrType, qr.additionalData
Pass qrType and additionalData through to the order verbatim. additionalData is opaque —
do not build or edit it.
4. Quote it
The quote tells you what the payout costs you in crypto, the fee, and the amount bounds. It reserves nothing.
const {data: quote} = await call('POST', '/v3/scan-to-pay/quotes', {
fiatCurrency: 'VND',
fiatAmount: 500_000,
}, token);
// quote.cryptoAmount debited from your fund, quote.fee, quote.minAmount, quote.maxAmount
fiatAmount is what the recipient receives; the fee is added on top.
5. Create the order
orderId is yours to choose, must be unique, and is the idempotency key.
const order = {
orderId: 'acme-1042', // yours, unique, stable across retries
fiatCurrency: 'VND',
fiatAmount: 500_000,
bankCode: qr.bankCode,
accountNumber: qr.accountNumber,
accountName: 'NGUYEN VAN A',
description: 'Thanh toan don hang 1042',
qrType: qr.qrType,
additionalData: qr.additionalData,
webhookSecretKey: process.env.ALIX_WEBHOOK_SECRET, // omit to skip the callback
};
let created;
try {
({data: created} = await call('POST', '/v3/scan-to-pay/orders', order, token));
} catch (err) {
if (err.code !== 'DUPLICATE_TRANSACTION_ID') throw err;
// The earlier attempt landed after all. Fetch it — do not create a second order.
({data: created} = await call('GET', `/v3/scan-to-pay/orders/${order.orderId}`, null, token));
}
// created.status === 'PROCESSING' — the payout is asynchronous.
Retrying with the same orderId is what makes a timed-out create safe. Generating a fresh id on
retry is how you pay twice.
6. Settle it
Register a webhook and you are told when the order reaches SUCCESS or
FAILED. It is delivered once and never retried, so keep a reconciliation job that polls
GET /v3/scan-to-pay/orders/{orderId} for anything still PROCESSING.
On the sandbox you can end the order yourself, which is also how you test the webhook handler:
await call('POST', '/v3/simulate/scan-to-pay/outcomes', {
orderId: 'acme-1042',
outcome: 'SUCCESS', // or FAILED — test both branches
}, token);
Next
- Sandbox testing — the test pass to run before going live.
- Error codes — what each failure means and which are worth retrying.
- API Explorer — every endpoint, runnable, with the full request and response schema.