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.
https://sandbox.api.acute.network/v1https://api.acute.network/v1Your 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: Bearer acuinf_test_…Keys are environment-scoped and prefix-encoded:
| Prefix | Environment | Touches 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.
| Scope | Grants |
|---|---|
wallet | Create / list / read wallets, submit KYC, read balance, withdraw |
payment | Create / list / read payments, refund, pay from wallet |
transfer | Wallet → wallet transfers |
payout | Batch 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.
{
"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).
{
"success": true,
"statusCode": 200,
"data": [ /* …WalletResponseData[] … */ ],
"pagination": {
"limit": 20,
"hasMore": true,
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2…"
},
"meta": { "requestId": "req_4f9c2a7e1b0d8c3a5e6f10a2" }
}{
"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:
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.
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_errorValidation errors (400) carry a per-field breakdown:
{
"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.
| Header | Direction | Notes |
|---|---|---|
Authorization | request | Bearer acuinf_{test|live}_…. Required on all but /health. |
Content-Type | request | application/json on every POST. |
Idempotency-Key | request | Required on the six money POSTs (below). |
X-Request-Id | response | Echoes meta.requestId. Format req_<24 hex>. On every response. |
Retry-After | response | Seconds 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:
| Endpoint | Scope |
|---|---|
POST /wallets/:id/transfer | transfer |
POST /wallets/:id/withdraw | wallet |
POST /wallets/:id/pay | payment |
POST /payments | payment |
POST /payments/:id/refund | payment |
POST /payouts | payout |
Send a unique key (a UUID is ideal) per logical operation:
Idempotency-Key: 9f2c8a1e-7b34-4d5a-9e1f-22c0d8b6a4e5Semantics:
- 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: 20Page size. Clamped to [1, 100]; out-of-range or non-numeric values fall back
to 20.
cursorstringqueryoptionalAn 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:
limitintegerrequiredThe resolved page size actually applied.
hasMorebooleanrequiredtrue if another page exists. Stop when it's false.
nextCursorstring | nullrequiredThe token for the next page, or null on the last page.
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:
{
"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.