Every failed request returns the same envelope. Handle errors by branching on success and the HTTP status — not by string-matching messages.
{
"success": false,
"statusMessage": "Insufficient free balance for this order",
"error": { "code": "400", "message": "Insufficient free balance for this order" },
"httpStatusCode": 400
}success
Always false on error — branch on this first.
error.code
String status code, mirrors the HTTP status.
error.message / statusMessage
Human-readable — for logs, not for control flow.
httpStatusCode
Numeric HTTP status, echoed in the body.
Validation errors are 400, often with several issues joined into one message.
200
Success (read/action)
Read data.
201
Write accepted
Read data.tempId, then poll.
400
Invalid input / failed rule (e.g. insufficient balance)
Fix the request; don't retry unchanged.
401
Not authenticated
Refresh credentials.
403
Not permitted (missing delegated permission)
Delegate the permission.
404
Resource not found
Check the id / path.
429
Throttled
Back off and retry.
500 / 503
Server / dependency error
Retry with backoff.
Because trades are asynchronous, a trade can fail at two moments:
Request-time (synchronous) — auth, validation, and permission errors come back immediately as a 4xx. The trade never started.
Execution-time (asynchronous) — a trade that was accepted (201 + tempId) can still fail later (solver rejects, order expires, on-chain revert). This surfaces in the status endpoint as a FAILED status, not as an HTTP error on the original call.
A
201means "accepted for processing," not "filled." Always confirm the terminal state via the matching.../statusendpoint. See How trading works.
Specific rate limits aren't published — build as if limits exist and may change.
4xx (except 429) are your bug — fix before retrying; don't hammer them.
429 and 5xx are transient — retry with exponential backoff + jitter.
Cap concurrency per key; poll status ~1s apart and stop on a terminal state.
For accepted trades that then fail, reconcile with GET /v1/positions/all before resubmitting, so you don't double-open a position that actually filled.
async function call(path, init) {
const res = await fetch(base + path, init);
const body = await res.json();
if (!body.success) {
if (res.status === 429 || res.status >= 500) throw new RetryableError(body.error?.message, res.status);
throw new ApiError(body.error?.message, res.status);
}
return body.data; // unwrap the envelope
}
