Quick start
An end-to-end scan-to-pay integration on the legacy v2 API, against the sandbox. Cashout is the
same shape with a different path and a recipient inside extendInfo.
If you are starting a new integration, use v3 instead. This page is for partners already on v2.
Before you start you need four things from onboarding: your partnerCode, your secretKey, an RSA
key pair (AliX holds the public half), and AliX's public key. Your calling IPs must be allowlisted
too, since every request checks them.
1. Sign a request
This is the whole of v2's security, and it is the part integrations get wrong. There is no login and no token: every request carries its own signature in the body.
The signed string is not the JSON body. It is a fixed, ordered, pipe-joined subset of that endpoint's
fields, with your secretKey glued on the end. There is no separator before it, which is why every
string ends in a pipe:
partnerCode|currency| + secretKey → ACME|USDT|SK_abc123
Which fields, and in what order, differs for every endpoint. Each reference page prints its
exact string. Get one wrong and you get INVALID_SIGNATURE with nothing to say which field it was.
Sign with RSA-SHA256, base64. Every step below goes through the same two helpers. Note what call()
branches on — success and semanticCode, never the HTTP status, which in v2 is 201 even when the
call failed:
import {createSign, createVerify} from 'node:crypto';
import {readFileSync} from 'node:fs';
const BASE_URL = 'https://sandbox.alixpay.com';
const PARTNER_CODE = 'YOUR_PARTNER_CODE';
const SECRET_KEY = process.env.ALIX_SECRET_KEY;
const privateKey = readFileSync('partner-private.pem', 'utf8'); // yours — never leaves your server
const alixPublicKey = readFileSync('alix-public.pem', 'utf8'); // AliX's — to verify replies
function signature(canonical) {
const signer = createSign('RSA-SHA256');
signer.update(canonical + SECRET_KEY); // secretKey is appended, not separated
signer.end();
return signer.sign(privateKey, 'base64');
}
async function call(path, body, canonical) {
const res = await fetch(`${BASE_URL}${path}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({...body, signature: signature(canonical)}),
});
// res.ok is meaningless here — a business error is HTTP 201 with success: false.
const {success, semanticCode, message, data} = await res.json();
if (!success) throw new Error(`${semanticCode}: ${message}`);
return data;
}
2. Verify your account
The right first call. It proves your partner code, your key, and your source IP are all correct, and it changes nothing.
const account = await call(
'/api/v2/account/verify',
{partnerCode: PARTNER_CODE},
`${PARTNER_CODE}|`, // signed string: partnerCode|secretKey
);
console.log(account.name, account.status); // → Acme Corp ACTIVE
If this fails, stop and fix it, because nothing else will work. INVALID_SIGNATURE means your
canonical string or your key is wrong. PARTNER_IP_NOT_ALLOWED means the machine you are calling from
is not on the allowlist.
Verify the reply, too
AliX signs its successful responses back to you. Rebuild the response's own canonical string, which is
a subset of the response fields with your secretKey as the last segment, and verify it with AliX's
public key:
function verify(canonical, sig) {
return createVerify('RSA-SHA256')
.update(canonical + SECRET_KEY)
.verify(alixPublicKey, sig, 'base64');
}
// account/verify signs: name|email|status|secretKey
if (!verify(`${account.name}|${account.email}|${account.status}|`, account.signature)) {
throw new Error('response signature did not verify');
}
Error responses are not signed; they carry data: null.
3. Onboard the user
Transaction endpoints reject an unverified user. Submit their documents once, then poll until
VERIFIED.
Every user is verified in full, so send the whole identity set. Leave anything out and the call comes
back with INVALID_REQUEST_DATA and a message naming what was missing.
Images must be base64 data URLs: data:image/jpeg;base64,…, not bare base64. That is the single most
common failure.
await call(
'/api/v2/user/submit-kyc',
{
partnerCode: PARTNER_CODE,
userEmail,
firstName: 'JANE',
lastName: 'DOE',
nationality: 'VN', // ISO 3166-1 alpha-2
type: 'ID_CARD', // or PASSPORT — then backIdImage is ignored
nationalId: '001199012345',
dateOfBirth: '1990-01-15',
gender: 'female',
issueDate: '2021-03-10',
expiryDate: '2031-03-10',
frontIdImage: `data:image/jpeg;base64,${front}`,
backIdImage: `data:image/jpeg;base64,${back}`,
holdIdImage: `data:image/jpeg;base64,${selfie}`,
kycReport: `data:application/pdf;base64,${report}`,
},
`${PARTNER_CODE}|${userEmail}|VN|`, // partnerCode|userEmail|nationality|secretKey
);
Then poll until the status settles. PROCESSING means it is under review; REJECTED comes back with
a rejectReason and almost nothing else.
const {kycStatus, rejectReason} = await call(
'/api/v2/user/get-kyc-information',
{partnerCode: PARTNER_CODE, userEmail},
`${PARTNER_CODE}|${userEmail}|`, // partnerCode|userEmail|secretKey
);
See KYC for the full field table and the gotchas — the images come back as URLs, not base64, and the document type is never returned.
4. Decode the QR
Your user scans a merchant QR. This endpoint is public: no signature, no partnerCode.
Keep recipientName and additionalData exactly as they come back. They are opaque; you pass them
through verbatim in the next step. Do not parse or rebuild them.
const res = await fetch(
`${BASE_URL}/api/v2/public/get-qr-code-info?qrContent=${encodeURIComponent(qrContent)}`,
);
const {data: qr} = await res.json();
// → { bankCode, bankName, bankAccountNumber, recipientName, countryCode, amount, qrType, additionalData }
5. Quote it
What the order will cost in crypto, before you commit. Nothing is created and nothing is debited.
const quote = await call(
'/api/v2/orders/review-sell-order',
{partnerCode: PARTNER_CODE, currency: 'USDT', fiatCurrency: 'PHP', fiatAmount: 500, userEmail},
`${PARTNER_CODE}|USDT|500|`, // partnerCode|currency|fiatAmount|secretKey
);
console.log(quote.tokenAmount, quote.totalPayment, quote.systemFee, quote.processingFee);
If you omit fiatAmount, the literal 0 takes its place in the signed string — sign …|0|, not
…||.
6. Create the order
This debits your fund and pays the merchant.
externalOrderId is yours, and it doubles as the idempotency key: a repeat returns
DUPLICATE_TRANSACTION_ID, which usually means your retry already worked. webhookSecretKey binds the
order to a webhook endpoint you registered, and omitting it means no callback is ever sent.
const externalOrderId = 'acme-1042';
const fiatAmount = 500;
const bankCode = qr.bankCode;
const bankAccountNumber = qr.bankAccountNumber;
const content = 'coffee';
const order = await call(
'/api/v2/orders/create-sell-order',
{
partnerCode: PARTNER_CODE,
externalOrderId,
currency: 'USDT',
fiatCurrency: 'PHP',
fiatAmount,
bankCode,
bankAccountNumber,
content,
userEmail,
webhookSecretKey: process.env.ALIX_WEBHOOK_SECRET,
extendInfo: {
qrType: qr.qrType,
PH: {recipientName: qr.recipientName, additionalData: qr.additionalData},
},
},
// partnerCode|externalOrderId|currency|fiatAmount|bankCode|bankAccountNumber|content|userEmail|secretKey
`${PARTNER_CODE}|${externalOrderId}|USDT|${fiatAmount}|${bankCode}|${bankAccountNumber}|${content}|${userEmail}|`,
);
console.log(order.status); // → AWAITING_PAYMENT
Two traps in that canonical string, both from the reference page: an omitted fiatAmount becomes the
literal 0, and bankAccountNumber is part of the string even though omitting it is not rejected as
a missing field — leave it out and you are signing the literal text undefined.
extendInfo carries the decoded QR through to the payment provider. Which country key it reads
depends on fiatCurrency — PH, GE, BR, AR, PE. For VND and the rest, send
extendInfo: null. See Scan-to-Pay.
7. Settle it
Orders settle asynchronously, and the webhook alone is not enough — do both of these.
AliX POSTs the full order to your registered endpoint when it reaches SUCCESS or ERROR. The
signature is a field in the body, over four fields only:
app.post('/webhooks/alix', express.json(), (req, res) => {
const o = req.body;
const canonical = `${o.externalOrderId}|${o.type}|${o.fiatAmount}|${o.status}|`;
if (!verify(canonical, o.signature)) return res.sendStatus(401);
res.sendStatus(200); // acknowledge first — there is no second delivery
void reconcile(o.externalOrderId); // then fetch the truth
});
The callback is delivered once and never retried. One timeout, one 500, one DNS blip and it is gone for good. So poll the order as the source of truth — for anything still non-terminal past its expected settlement time:
const detail = await call(
'/api/v2/orders/details',
{partnerCode: PARTNER_CODE, externalOrderId},
`${PARTNER_CODE}|${externalOrderId}|`, // partnerCode|externalOrderId|secretKey
);
// status: AWAITING_PAYMENT | PAYMENT_COMPLETED | PROCESSING | SUCCESS | ERROR
SUCCESS and ERROR are terminal. On ERROR your fund has already been refunded.
Next
- Authentication — the signing scheme in full, and how to verify AliX's replies.
- Error codes — every code, and the two traps in the table.
- Cashout — the same flow, paying a bank account directly. Its signed string is not the one the old docs printed.