API conventions

The base URL, the response envelope, auth, scopes, idempotency, pagination, and errors: everything that holds across every Acute Infra endpoint.

One envelope, one auth scheme, two hosts. Learn these once and every endpoint in the reference reads the same. This page is the contract the rest of the reference assumes. Bookmark it.

There are two hosts, one per environment. Each path in this reference is relative to whichever base matches your key.

Sandbox (test) base
text
https://sandbox.api.acute.network/v1
Live (production) base
text
https://api.acute.network/v1

Your key's prefix must match the host you call: a test key (acuinf_test_…) works only on the sandbox host, and a live key (acuinf_live_…) works only on the live host. Send a key to the wrong host and the request fails with 401 (API_KEY_ENVIRONMENT_MISMATCH) before it reaches a handler (see Environments). The only unauthenticated route is GET /health; everything else needs a key.

Send your secret key as a Bearer token on every request:

Authorization header
http
Authorization: Bearer acuinf_test_…

Keys are environment-scoped and prefix-encoded:

PrefixEnvironmentTouches real money
acuinf_test_…Test (sandbox)No
acuinf_live_…Live (production)Yes

Treat the live key like a password

A live secret key moves real money. Keep it server-side, never in client code or a repo, and never paste it into the docs playground: the <ApiExplorer> and its proxy reject live keys outright.

A missing key returns 401 (API_KEY_MISSING); a malformed or unknown key returns 401 (API_KEY_INVALID); a key whose environment doesn't match the host returns 401 (API_KEY_ENVIRONMENT_MISMATCH). See Authentication for key lifecycle and rotation.

Every key carries one or more scopes. A request to an endpoint your key doesn't hold the scope for is rejected with 403 (API_KEY_SCOPE_FORBIDDEN): the details name the requiredScopes and your providedScopes.

ScopeGrants
walletCreate / list / read wallets, submit KYC, read balance, withdraw
paymentCreate / list / read payments, refund, pay from wallet
transferWallet → wallet transfers
payoutBatch payouts

Scopes don't always match the URL

Two money actions live under /wallets/:id/… but need a different scope: POST /wallets/:id/transfer needs transfer, and POST /wallets/:id/pay needs payment. Only POST /wallets/:id/withdraw uses the wallet scope. The per-operation banner in the reference always shows the real one.

Every response (success or failure) is JSON in one of three shapes. The shape is stable and typed; copy the TypeScript types into your project so your client can be fully typed end to end. Money is always in kobo (integer minor units); a resource's public id is always its acuinf… reference.

No `message` on success

Unlike some APIs, a successful response carries no top-level message string. The payload is data + meta, nothing else. Branch on statusCode and the typed data, not prose.

200 OK · GET /wallets/:id
json
{
  "success": true,
  "statusCode": 200,
  "data": {
    "id": "acuinf483920175566wlt",
    "kind": "end_user",
    "email": "ada@example.com",
    "fullName": "Ada Lovelace",
    "phone": null,
    "externalReference": "cust_8842",
    "kycStatus": "tier1",
    "status": "active",
    "currency": "NGN",
    "createdAt": "2026-06-24T09:14:02.118Z"
  },
  "meta": { "requestId": "req_4f9c2a7e1b0d8c3a5e6f10a2" }
}

A list response adds a pagination block; data is an array. There is no total count: this is cursor (keyset) pagination (below).

200 OK · GET /wallets
json
{
  "success": true,
  "statusCode": 200,
  "data": [ /* …WalletResponseData[] … */ ],
  "pagination": {
    "limit": 20,
    "hasMore": true,
    "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2…"
  },
  "meta": { "requestId": "req_4f9c2a7e1b0d8c3a5e6f10a2" }
}

422 Unprocessable · insufficient funds
json
{
  "success": false,
  "statusCode": 422,
  "error": {
    "type": "unprocessable_error",
    "code": "WALLET_INSUFFICIENT_FUNDS",
    "message": "Wallet balance is too low for this transfer.",
    "details": {}
  },
  "meta": { "requestId": "req_4f9c2a7e1b0d8c3a5e6f10a2" }
}

Type-narrow on success, then read either data/pagination or error:

exhaustive handling
ts
import type { ApiResult } from "./acute"; // the types from the SDK & types page
 
function unwrap<T>(res: ApiResult<T>): T {
  if (res.success) return res.data; // ApiSuccess<T> | ApiPaginated<T>
  // res.error is the discriminated ApiError union: branch on code.
  throw new Error(`${res.error.code}: ${res.error.message}`);
}

Failures carry the reason so you can be as coarse or as precise as you like:

  • type: the broad category (validation_error, authentication_error, authorization_error, not_found_error, conflict_error, unprocessable_error, rate_limit_error, internal_error).
  • code: the stable, granular machine code (e.g. WALLET_INSUFFICIENT_FUNDS, VALIDATION_FAILED). Branch on this for precise handling; it's the one that survives across releases.
  • details: type-specific structured context.
HTTP status → error type
text
400  validation_error       422  unprocessable_error
401  authentication_error   429  rate_limit_error
403  authorization_error    500  internal_error
404  not_found_error        502/503/504  internal_error
409  conflict_error

Validation errors (400) carry a per-field breakdown:

400 Bad Request · validation
json
{
  "success": false,
  "statusCode": 400,
  "error": {
    "type": "validation_error",
    "code": "VALIDATION_FAILED",
    "message": "The request failed validation.",
    "details": {
      "fields": [
        { "field": "email", "code": "invalid_string", "message": "Invalid email" }
      ]
    }
  },
  "meta": { "requestId": "req_…" }
}

The full error catalog lives in the error reference. When you contact support, send the meta.requestId: it pins the exact request.

HeaderDirectionNotes
AuthorizationrequestBearer acuinf_{test|live}_…. Required on all but /health.
Content-Typerequestapplication/json on every POST.
Idempotency-KeyrequestRequired on the six money POSTs (below).
X-Request-IdresponseEchoes meta.requestId. Format req_<24 hex>. On every response.
Retry-AfterresponseSeconds to wait, on 429 only.

Log the request id

X-Request-Id (req_ + 24 hex chars) is identical to meta.requestId. Log it with every call: it's the single handle that lets support trace a request end to end.

Money POSTs are guarded by an Idempotency-Key so a retried request never moves money twice. These six endpoints require the header:

EndpointScope
POST /wallets/:id/transfertransfer
POST /wallets/:id/withdrawwallet
POST /wallets/:id/paypayment
POST /paymentspayment
POST /payments/:id/refundpayment
POST /payoutspayout

Send a unique key (a UUID is ideal) per logical operation:

http
Idempotency-Key: 9f2c8a1e-7b34-4d5a-9e1f-22c0d8b6a4e5

Semantics:

  • Replay: same key, same body → the cached result is returned with a fresh meta.requestId. Safe to retry on a network blip.
  • Reuse with a different body: same key, different payload → 409 (IDEMPOTENCY_KEY_REUSED). Keys are not reusable across distinct operations.
  • In flight: the same key is mid-processing → 409 (IDEMPOTENCY_IN_PROGRESS). Back off and retry.
  • Missing: no key on a money POST → 400 (IDEMPOTENCY_KEY_MISSING).

The idempotency guide has the full retry playbook.

List endpoints use cursor (keyset) pagination over (createdAt, id) descending. There is no page number and no total count: you follow the cursor until the well runs dry.

limitintegerqueryoptionaldefault: 20

Page size. Clamped to [1, 100]; out-of-range or non-numeric values fall back to 20.

cursorstringqueryoptional

An opaque token from the previous page's pagination.nextCursor. Omit it for the first page. Don't construct or parse it: pass it back verbatim.

Each response's pagination block tells you whether to keep going:

limitintegerrequired

The resolved page size actually applied.

hasMorebooleanrequired

true if another page exists. Stop when it's false.

nextCursorstring | nullrequired

The token for the next page, or null on the last page.

drain every page
ts
async function* listAllWallets(key: string) {
  let cursor: string | null = null;
  do {
    const url = new URL("https://sandbox.api.acute.network/v1/wallets");
    url.searchParams.set("limit", "100");
    if (cursor) url.searchParams.set("cursor", cursor);
 
    const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
    const page = await res.json();
    yield* page.data;
    cursor = page.pagination.nextCursor;
  } while (cursor);
}

Each API key is limited to 600 requests per minute. Over the limit, you get 429 (RATE_LIMIT_EXCEEDED) with a Retry-After header and a structured details block:

429 Too Many Requests
json
{
  "success": false,
  "statusCode": 429,
  "error": {
    "type": "rate_limit_error",
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Too many requests.",
    "details": {
      "limit": 600,
      "remaining": 0,
      "resetAt": "2026-06-24T09:15:00.000Z",
      "retryAfterSeconds": 12
    }
  },
  "meta": { "requestId": "req_…" }
}

Honor Retry-After (or details.retryAfterSeconds) and back off. Don't hammer.