Errors
The error envelope, the discriminated ApiError union, the granular code you branch on, and the requestId you give support.
Errors are part of the contract, not an afterthought. Every failure from
api.acute.network/v1 comes back in the same typed envelope as a success:
same shape, same meta, just success: false and an error object instead of
data. You can type it, branch on it, and trace it. This guide is the practical
half; the error catalog is the exhaustive list of every
code.
{
"success": false,
"statusCode": 422,
"error": {
"type": "unprocessable_error",
"code": "WALLET_INSUFFICIENT_FUNDS",
"message": "The source wallet has insufficient funds for this transfer.",
"details": {}
},
"meta": { "requestId": "req_9c1a4e7b2d6f8a0c3b5e9d12" }
}successfalserequiredAlways false on an error. Discriminate on this first.
statusCodenumberrequiredThe HTTP status, mirrored into the body so you have it even when your client abstracts the status away.
errorApiErrorrequiredThe discriminated error union (below).
meta.requestIdstringrequiredThe correlation id for this request (req_<24 hex>), also in the
X-Request-Id response header. Quote this to support: it pins the exact
exchange in our logs.
No top-level message
There is no top-level message on success; a success is
{ success, statusCode, data, meta }. On an error, the human-readable text
lives at error.message.
The error object carries the reason at two levels of granularity. Use the
right one for the job:
| Field | Granularity | Use it to |
|---|---|---|
type | coarse category (8 of them) | Pick a broad strategy: retry, re-auth, show a field error. |
code | the precise machine code | Branch on the exact failure (WALLET_KYC_REQUIRED vs WALLET_TIER1_LIMIT_EXCEEDED). |
Branch on
code.typetells you what kind of problem;codetells you which problem. Build your handling aroundcode, fall back totypefor anything you haven't special-cased.
The ApiError union is discriminated by type, and each type pins a status
code and a details shape:
type | Status | details | Meaning |
|---|---|---|---|
validation_error | 400 | { fields: ApiFieldError[] } | The request body/query/params failed validation. |
authentication_error | 401 | {} | Missing or invalid API key. |
authorization_error | 403 | { requiredScopes?, providedScopes? } | The key (or wallet) may not do this: scope or KYC gate. |
not_found_error | 404 | { resource, id } | No such resource. |
conflict_error | 409 | Record<string, unknown> | Conflicts with existing state (duplicate email, idempotency reuse). |
unprocessable_error | 422 | Record<string, unknown> | Well-formed but a business rule rejects it (insufficient funds, KYC limit). |
rate_limit_error | 429 | { limit, remaining, resetAt, retryAfterSeconds } | Too many requests for this key. |
internal_error | 5xx | { retryable, service? } | An error on Acute's side or an upstream provider. |
A 400 lists exactly what's wrong, field by field:
{
"success": false,
"statusCode": 400,
"error": {
"type": "validation_error",
"code": "VALIDATION_FAILED",
"message": "The request failed validation.",
"details": {
"fields": [
{ "field": "amount", "code": "too_small", "message": "Amount must be at least 100 kobo." },
{ "field": "items.0.accountNumber", "code": "invalid_string", "message": "Must be a 10-digit NUBAN." }
]
}
},
"meta": { "requestId": "req_3a7c9e1b5d2f8a0c4b6e9d22" }
}field is a dotted path (items.0.amount), so you can map each error straight
to a form input.
Copy the TypeScript types into your project. Narrow on
success, then on error.type:
import type { ApiResult, ApiError } from "./acute"; // the types from the SDK & types page
function handle<T>(result: ApiResult<T>): T {
if (result.success) return result.data as T;
const error: ApiError = result.error;
switch (error.type) {
case "validation_error":
throw new FormError(error.details.fields); // typed: ApiFieldError[]
case "rate_limit_error":
return retryAfter(error.details.retryAfterSeconds); // typed: number
case "authentication_error":
throw new ReauthRequired(); // your key is bad
default:
// Branch the precise cases on the granular code.
if (error.code === "WALLET_KYC_REQUIRED") promptKyc();
throw new ApiFailure(error.code, result.meta.requestId);
}
}| Status | Retry? | How |
|---|---|---|
400 / 422 | No | The request is wrong. Fix it. |
401 / 403 | No | Re-auth or fix scopes/KYC. |
404 | No | Wrong id. |
409 | Sometimes | IDEMPOTENCY_IN_PROGRESS → back off and retry the same key. A true conflict → don't. |
429 | Yes | Wait Retry-After seconds, then retry. |
5xx | Yes | Backoff. On a money POST, retry with the same Idempotency-Key so it can't double-apply. |
On a 5xx, retry, but with the same idempotency key
A 500 or 503 on a money POST is ambiguous: the operation may or may not have
applied. Retrying with the same Idempotency-Key is
what makes that safe: the replay returns the original outcome instead of moving
money twice.
If an upstream provider is unavailable, money-movement POSTs return a 5xx with
a retryable internal_error, while reads stay up:
{
"success": false,
"statusCode": 503,
"error": {
"type": "internal_error",
"code": "UPSTREAM_ERROR",
"message": "Money movement is temporarily unavailable. Retry shortly.",
"details": { "retryable": true }
},
"meta": { "requestId": "req_8b2d4f6a0c1e3a5b7d9f1c44" }
}Back off and retry the same Idempotency-Key until it clears.