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.

a failed response
json
{
  "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" }
}
successfalserequired

Always false on an error. Discriminate on this first.

statusCodenumberrequired

The HTTP status, mirrored into the body so you have it even when your client abstracts the status away.

errorApiErrorrequired

The discriminated error union (below).

meta.requestIdstringrequired

The 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:

FieldGranularityUse it to
typecoarse category (8 of them)Pick a broad strategy: retry, re-auth, show a field error.
codethe precise machine codeBranch on the exact failure (WALLET_KYC_REQUIRED vs WALLET_TIER1_LIMIT_EXCEEDED).

Branch on code. type tells you what kind of problem; code tells you which problem. Build your handling around code, fall back to type for anything you haven't special-cased.

The ApiError union is discriminated by type, and each type pins a status code and a details shape:

typeStatusdetailsMeaning
validation_error400{ fields: ApiFieldError[] }The request body/query/params failed validation.
authentication_error401{}Missing or invalid API key.
authorization_error403{ requiredScopes?, providedScopes? }The key (or wallet) may not do this: scope or KYC gate.
not_found_error404{ resource, id }No such resource.
conflict_error409Record<string, unknown>Conflicts with existing state (duplicate email, idempotency reuse).
unprocessable_error422Record<string, unknown>Well-formed but a business rule rejects it (insufficient funds, KYC limit).
rate_limit_error429{ limit, remaining, resetAt, retryAfterSeconds }Too many requests for this key.
internal_error5xx{ retryable, service? }An error on Acute's side or an upstream provider.

A 400 lists exactly what's wrong, field by field:

400, validation_error
json
{
  "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:

handle.ts
ts
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);
  }
}

StatusRetry?How
400 / 422NoThe request is wrong. Fix it.
401 / 403NoRe-auth or fix scopes/KYC.
404NoWrong id.
409SometimesIDEMPOTENCY_IN_PROGRESS → back off and retry the same key. A true conflict → don't.
429YesWait Retry-After seconds, then retry.
5xxYesBackoff. 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:

503 upstream unavailable
json
{
  "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.