Skip to main content

Response format

The envelope

Every response under /api/v2 is wrapped in the same five fields, whether it succeeded or failed:

JSON
{
"success": true,
"message": "Your request has been successful",
"data": { "balance": 1500.25, "currency": "USDT", "signature": "K3tQ...==" },
"errorCode": 0,
"semanticCode": "SUCCESS"
}
FieldMeaning
successtrue on success, false on any error.
messageHuman-readable English. Always English — the lang header is ignored.
dataThe payload on success, and null on every error.
semanticCodeA string, and the field to branch on. SUCCESS, or one of the error codes.
errorCodeA number. Legacy, kept for backward compatibility.

On failure:

JSON
{
"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.

SituationHTTP status
Success201 (200 on the one GET)
Any business error201 — read semanticCode
Request validation failed200, 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.

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