Requests and responses
The response envelope, error codes, money, pagination, and idempotency.
Contents
The response envelope
Every response — success or failure — is a JSON object with exactly one of data or error at the top level. There is no success: true flag to check; the HTTP status and the presence of error tell you everything.
{
"data": { "reference": "col_3a7d5e1f9b2c4d68", "status": "succeeded" }
}{
"data": [ { "reference": "col_3a7d..." }, { "reference": "col_8b2e..." } ],
"meta": { "nextCursor": "652f1a9c4d7b3e0812345678", "hasMore": true }
}{
"error": {
"code": "INSUFFICIENT_BALANCE",
"message": "Insufficient balance for this payout.",
"details": {}
}
}details is present only when there is something structured to say. For a 422 it maps a field name to the problem with that field, which is exactly what a form needs.
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": {
"amount.value": "Expected number, received string",
"customerEmail": "Invalid email"
}
}
}Errors
Branch on error.code, never on error.message. Codes are stable; messages are written for humans and will be reworded.
Status codes
| Field | Type | Description |
|---|---|---|
| 400 | Bad Request | The request is well-formed but cannot be acted on. |
| 401 | Unauthorized | Missing, malformed, or revoked API key. |
| 403 | Forbidden | Authenticated, but not permitted — usually a missing scope. |
| 404 | Not Found | No such resource, or it belongs to another merchant. |
| 409 | Conflict | The resource is not in a state that allows this. |
| 422 | Unprocessable Entity | Validation failed. See details for the offending fields. |
| 429 | Too Many Requests | Rate limited. Honour Retry-After. |
| 500 | Internal Server Error | Our fault. Safe to retry an idempotent request. |
Common error codes
| Field | Type | Description |
|---|---|---|
| AUTH_REQUIRED | 401 | No Authorization header, or it is not a bearer token. |
| AUTH_INVALID | 401 | The key does not exist, or has been revoked. |
| SCOPE_INSUFFICIENT | 403 | Valid key, but it lacks the scope this route needs. |
| IP_NOT_ALLOWED | 403 | The request came from an address outside this key's allowlist. |
| KYB_LIVE_REQUIRED | 403 | A live action attempted before business verification was approved. |
| VALIDATION_ERROR | 422 | One or more fields failed validation. See details. |
| RATE_LIMITED | 429 | Too many requests for this key. |
| IDEMPOTENCY_KEY_REQUIRED | 400 | A payout was sent without an Idempotency-Key header. |
| IDEMPOTENCY_KEY_CONFLICT | 409 | That key was already used with a different request body. |
| INSUFFICIENT_BALANCE | 400 | Your ledger balance does not cover the payout plus its fee. |
| PAYOUT_DESTINATION_REQUIRED | 400 | No destination given and no default payout account set. |
404 also means “not yours”
404, not 403. A 403 would confirm the reference exists, which is itself information we will not give out.Money
Amounts are always an object with an integer value in the currency’s minor unit and an ISO currency code. For NGN the minor unit is kobo, so ₦1.00 is 100. Floats appear nowhere in the API, in either direction.
{ "value": 500000, "currency": "NGN" } // ₦5,000.00
{ "value": 100, "currency": "NGN" } // ₦1.00
{ "value": 1, "currency": "NGN" } // ₦0.01Never build an amount by multiplying a float
Math.round(49.99 * 100) is 4999, but Math.round(1.005 * 100) is 100, not 101. Keep prices in minor units from the moment they enter your system and you never meet this class of bug.Pagination
List endpoints are cursor-paginated. Pass limit (1–100, default 20) and, for pages after the first, the cursor from the previous response’s meta. Results are newest-first.
curl "https://api.radokpay.com/v1/collections?limit=2" \
-H "Authorization: Bearer $RADOK_SECRET_KEY"{
"data": [
{ "reference": "col_8b2e...", "status": "succeeded" },
{ "reference": "col_3a7d...", "status": "awaiting_payment" }
],
"meta": {
"nextCursor": "652f1a9c4d7b3e0812345678",
"hasMore": true
}
}async function* allCollections() {
let cursor;
do {
const url = new URL("https://api.radokpay.com/v1/collections");
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const response = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.RADOK_SECRET_KEY}` },
});
const { data, meta } = await response.json();
yield* data;
// Stop on hasMore, not on an empty page — the last page is usually non-empty.
cursor = meta.hasMore ? meta.nextCursor : undefined;
} while (cursor);
}Treat the cursor as opaque
Idempotency
POST /v1/payouts requires an Idempotency-Key header. Sending the same key twice returns the original payout instead of creating a second one — which is what makes a network timeout safe to retry.
curl -X POST https://api.radokpay.com/v1/payouts \
-H "Authorization: Bearer $RADOK_SECRET_KEY" \
-H "Idempotency-Key: 8f14e45f-ea38-4b1e-9c22-3d1c7a90b6f2" \
-H "Content-Type: application/json" \
-d '{ "amount": { "value": 250000, "currency": "NGN" } }'- Generate the key once per logical action, not once per attempt. A fresh UUID on every retry defeats the entire mechanism.
- Reusing a key with a different body returns
409 IDEMPOTENCY_KEY_CONFLICT. The comparison is over the whole body, recursively — changing onlyamount.valueis still a conflict. - Keys are scoped per merchant, so yours can never collide with another merchant’s.
Persist the key before you send the request
Timestamps and references
- All timestamps are ISO 8601 in UTC:
2026-08-23T11:04:52.000Z. - Resource references are prefixed and globally unique.
col_for collections,va_for virtual accounts,pyt_for payouts,evt_for webhook events. - Look resources up by their
reference, not by any internal id. The reference is what appears in webhooks and in the dashboard.
Retrying safely
GETrequests are always safe to retry.POST /v1/payoutsis safe to retry with the sameIdempotency-Key.- Other
POSTrequests are not idempotent. On a timeout, list the resource first to check whether it was created before sending again. - Back off exponentially and cap the attempts. On a
429, wait at leastRetry-Afterseconds.