Skip to main content

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.

JavaScript
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:

JavaScript
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.

JavaScript
const token = await accessTokenFor('[email protected]');

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.

JavaScript
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.

JavaScript
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.

JavaScript
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:

JavaScript
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.