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"
}
A business error returns HTTP 201
A business error returns the same HTTP status as a success, which for a POST is 201 Created. The
envelope then carries success: false and the semanticCode of the error.
| Situation | HTTP status |
|---|---|
| Success | 201 (200 on the one GET) |
| Any business error | 201 — read semanticCode |
| Request validation failed | 200, with errorCode 5 / INVALID_REQUEST_DATA |
errorCode 38 (LOGIN_SESSION_EXPIRED) | 401 |
So read the outcome from the envelope: check success, then switch on semanticCode.
const {success, semanticCode, message, data} = await res.json(); // the envelope, not res.ok, carries the outcome
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}`);
}
Branch on semanticCode
The numeric errorCode is a legacy field, kept so that older integrations keep working.
Fourteen numeric codes arrive as INTERNAL_SERVER_ERROR; errorCode is what tells them apart.
They are listed under codes identified by errorCode.
message is prose for logging rather than for matching. On a rejected request it is generated
("partnerCode should not be empty") and its wording can vary — branch on the codes, log the
message.
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 — local time in UTC+7, with no zone marker. Parse them
as Asia/Ho_Chi_Minh time.
Unknown fields
Extra fields you send are accepted and ignored, and a response may grow fields beyond the documented ones over time — build your parsing to tolerate both.
A misspelled field name is dropped like any other extra field, so the signature you computed over
it stops matching. When INVALID_SIGNATURE arrives on a request you believe is correct, field
names are worth a second look.