Skip to main content

Quick start

An end-to-end scan-to-pay integration, against the sandbox. Cashout is the same shape with a different path and a recipient inside extendInfo.

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

Every request carries its own signature in the body, made with your RSA private key. This is the whole of the security model, so it is the part to get right first. If you do not have a pair yet, generate one now — Generating your key pair is two OpenSSL commands.

The signed string is a fixed, ordered, pipe-joined subset of that endpoint's fields — not the JSON body — with your secretKey glued straight on the end, 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. A field out of place returns INVALID_SIGNATURE; when you see it, recheck the field order against the endpoint page.

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 is 201 even when the call failed:

JavaScript
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)}),
});

// a business error is HTTP 201 with success: false — read the envelope, not res.ok.
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.

JavaScript
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

Settle this call first — every other call authenticates the same way. INVALID_SIGNATURE points at the canonical string or the key; PARTNER_IP_NOT_ALLOWED points at the machine you are calling from, which needs to be on your 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:

JavaScript
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');
}

Signature verification applies to successful responses; an error response carries 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.

Format the documents as base64 data URLs, prefix included: data:image/jpeg;base64,…. A bare base64 string is rejected with 58, so the prefix is the detail to confirm first.

JavaScript
const userEmail = '[email protected]';

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 that tells you what to fix before resubmitting.

JavaScript
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 how the profile reads back.

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.

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

JavaScript
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; without it the order settles silently, read through orders/details.

JavaScript
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 details of that canonical string, both printed on the reference page: an omitted fiatAmount takes the literal 0 as its segment, and bankAccountNumber is signed whether or not you send it. Send every signed field and the string stays exactly as written above.

extendInfo carries the decoded QR through to the payment provider. Which country key it reads depends on fiatCurrencyPH, GE, BR, AR, PE. For VND and the rest, send extendInfo: null. See Scan-to-Pay.

7. Settle it

Orders settle asynchronously. The webhook tells you the moment it happens, and orders/details confirms it — the pair below is the reliable pattern.

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:

JavaScript
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, process after
void reconcile(o.externalOrderId); // then confirm with orders/details
});

Each callback is delivered once, at settlement. Acknowledge it immediately, and keep a reconciliation sweep that polls any order still non-terminal past its expected settlement time — orders/details is the source of truth:

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

Before production

Point BASE_URL at https://api.alixpay.com and walk the same path once more:

  • Run account/verify first — it confirms your partner code, your key, and your calling IP in one call.
  • Have your production egress IPs allowlisted, through onboarding.
  • Register your production webhook endpoint — sandbox and production endpoints are registered separately — and pass its secret when you create orders.
  • Have the reconciliation sweep from step 7 running before real money moves.

Next

  • Authentication — the signing scheme in full, and how to verify AliX's replies.
  • Error codes — every code, and how to read the code table.
  • Cashout — the same flow, paying a bank account directly, with its own signed string.