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 leaves through the same path as a success, and for a POST that path ends in HTTP
201 Created. 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 |
| 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. Two more
details round out the error model.
Fourteen numeric codes arrive as INTERNAL_SERVER_ERROR; errorCode is what tells them apart.
They are listed under unmapped codes.
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.