Response format
The envelope
Every response under /api/v2 is wrapped in the same five fields, whether it succeeded or failed:
{
"success": true,
"message": "Your request has been successful",
"data": { "balance": 1500.25, "currency": "USDT", "signature": "K3tQ...==" },
"errorCode": 0,
"semanticCode": "SUCCESS"
}
| Field | Meaning |
|---|---|
success | true on success, false on any error. |
message | Human-readable English. Always English — the lang header is ignored. |
data | The payload on success, and null on every error. |
semanticCode | A string, and the field to branch on. SUCCESS, or one of the error codes. |
errorCode | A number. Legacy, kept for backward compatibility. |
On failure:
{
"success": false,
"message": "The transaction id already exists.",
"data": null,
"errorCode": 11,
"semanticCode": "DUPLICATE_TRANSACTION_ID"
}
The HTTP status is not the error
A business error comes back as HTTP 201 Created. Errors are returned as values rather than thrown,
so they leave through the same path as a success, and for a POST that path ends in 201. An order
rejected for insufficient balance, a duplicate order id, a locked account, a bad signature: all of
them arrive as HTTP 201 with success: false.
| Situation | HTTP status |
|---|---|
| Success | 201 (200 on the one GET) |
| Any business error | 201 — read semanticCode, not the status |
| Request validation failed | 200, with errorCode 5 / INVALID_REQUEST_DATA |
errorCode 38 (LOGIN_SESSION_EXPIRED) | 401 — the only status that means anything |
So do not branch on the HTTP status in v2. Check success, then switch on semanticCode.
const {success, semanticCode, message, data} = await res.json(); // res.ok is meaningless here
if (success) return data;
switch (semanticCode) {
case 'DUPLICATE_TRANSACTION_ID':
return getOrder(externalOrderId); // your retry already worked
case 'PARTNER_INSUFFICIENT_BALANCE':
return topUpFundAndRetry();
default:
throw new Error(`${semanticCode}: ${message}`);
}
(v3 fixed this: its HTTP statuses are real. See v3 response format.)
semanticCode is the contract, errorCode is not
Branch on semanticCode. The numeric errorCode is a legacy field, kept so that older integrations
keep working, and two things about it are worth knowing.
Fourteen numeric codes have no semanticCode of their own. They all arrive as
INTERNAL_SERVER_ERROR, so to tell them apart you have to read errorCode. They are listed under
unmapped codes.
message is prose, and it is not stable. On a rejected request it is generated by the server
("partnerCode should not be empty") rather than written for you. Log it, but never match on it.
Amounts and timestamps
Fiat amounts are decimals in the currency's major unit: đồng, not hundredths of a đồng. Crypto amounts are decimals too, and your fund balance is truncated to 2 decimals rather than rounded.
Timestamps look like 2026-07-14 10:32:07. They are UTC+7 and carry no zone marker, so they are
neither UTC nor ISO-8601. Parse them as Asia/Ho_Chi_Minh local time.
Unknown fields
Requests are not checked strictly. Extra fields you send are accepted and ignored, and extra fields AliX adds to a response arrive without warning, so nothing you build should depend on a response having exactly the documented keys.
A typo in a field name is not rejected either. The field is simply dropped, and your signature stops matching.