Error model
The structure of error responses in V3 Custody: the error/message/code/details envelope, HTTP statuses, and machine-readable codes.
The API returns standard HTTP statuses with a single JSON error envelope. Validation errors include per-field details, and domain errors include a machine-readable code.
How to branch your logic on a response:
Error envelope
{
"error": "Bad Request",
"message": "The request contains invalid parameters or malformed data",
"code": 400,
"details": [
{ "field": "email", "message": "Invalid email format" }
]
}
error: a short error name (Bad Request,Unauthorized,Not Found…).message: a human-readable description.code: the HTTP status as a number.details: per-field errors (for400validation errors):field+message.
HTTP statuses
| Status | Meaning |
|---|---|
200 / 201 | Success |
202 | Queued: an approval request was created |
400 | Bad request (validation; see details) |
401 | Not authenticated |
403 | Forbidden by policy (deny/block; default-deny) |
404 | Not found |
410 | The resource is no longer valid (for example, a revoked or accepted invitation) |
422 | Unprocessable |
Machine-readable codes
In addition to the HTTP status, domain errors carry a machine-readable code, which is the most convenient thing to branch your logic on:
| Code | When |
|---|---|
INSUFFICIENT_BALANCE | Insufficient funds for the transfer |
AMOUNT_EXCEEDED | A policy limit was exceeded |
REPORT_PERIOD_TOO_LARGE | The report result is too large; narrow the filters |
REPORT_UNSUPPORTED_FORMAT | The export format isn't supported yet (xlsx/pdf) |
Where the machine-readable code lives varies by endpoint: in some it's a string code, in others a nested error.code, and for the Policy Engine it's the reason in the decision (with a trace of the matched policies). See the API Reference for the exact shape.
Handling errors
Check the response status and read code/details:
const res = await fetch(url, { headers });
if (!res.ok) {
const err = await res.json();
console.error(`${err.code} ${err.error}: ${err.message}`);
for (const d of err.details ?? []) {
console.error(` ${d.field}: ${d.message}`);
}
}
res = requests.post(url, headers=headers, json=body)
if not res.ok:
err = res.json()
print(f"{err['code']} {err['error']}: {err['message']}")
for d in err.get("details", []):
print(f" {d['field']}: {d['message']}")
When to retry
- 5xx and network failures: retry with exponential backoff. Retry transfers with the same
idempotencyKeyso the payment isn't duplicated (see Idempotency). 400/422: don't retry blindly; fix the request usingdetails.403: this is a Policy Engine decision. Checkreasonandtraceto see which policy blocked the request (see Policy Engine).
Common mistakes
| Symptom | Cause |
|---|---|
| branching on text | message is human-readable; branch on code or the machine-readable code |
403 mistaken for an authentication issue | 403 is a policy decision (deny), not a token problem; authentication failures are 401 |
202 mistaken for final success | it's the queue: an approval request was created, and the operation isn't final yet |
5xx isn't retried | retry with backoff (for transfers, with the same idempotencyKey) |
| looking for the code in one place | the code's location varies by endpoint (code / error.code / reason) |