RADOK

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.

Success
{
  "data": { "reference": "col_3a7d5e1f9b2c4d68", "status": "succeeded" }
}
Success with pagination metadata
{
  "data": [ { "reference": "col_3a7d..." }, { "reference": "col_8b2e..." } ],
  "meta": { "nextCursor": "652f1a9c4d7b3e0812345678", "hasMore": true }
}
Error
{
  "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.

422 Unprocessable Entity
{
  "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

FieldTypeDescription
400Bad RequestThe request is well-formed but cannot be acted on.
401UnauthorizedMissing, malformed, or revoked API key.
403ForbiddenAuthenticated, but not permitted — usually a missing scope.
404Not FoundNo such resource, or it belongs to another merchant.
409ConflictThe resource is not in a state that allows this.
422Unprocessable EntityValidation failed. See details for the offending fields.
429Too Many RequestsRate limited. Honour Retry-After.
500Internal Server ErrorOur fault. Safe to retry an idempotent request.

Common error codes

FieldTypeDescription
AUTH_REQUIRED401No Authorization header, or it is not a bearer token.
AUTH_INVALID401The key does not exist, or has been revoked.
SCOPE_INSUFFICIENT403Valid key, but it lacks the scope this route needs.
IP_NOT_ALLOWED403The request came from an address outside this key's allowlist.
KYB_LIVE_REQUIRED403A live action attempted before business verification was approved.
VALIDATION_ERROR422One or more fields failed validation. See details.
RATE_LIMITED429Too many requests for this key.
IDEMPOTENCY_KEY_REQUIRED400A payout was sent without an Idempotency-Key header.
IDEMPOTENCY_KEY_CONFLICT409That key was already used with a different request body.
INSUFFICIENT_BALANCE400Your ledger balance does not cover the payout plus its fee.
PAYOUT_DESTINATION_REQUIRED400No destination given and no default payout account set.

404 also means “not yours”

Asking for a resource belonging to another merchant returns 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.

Money
{ "value": 500000, "currency": "NGN" }   // ₦5,000.00
{ "value": 100,    "currency": "NGN" }   // ₦1.00
{ "value": 1,      "currency": "NGN" }   // ₦0.01

Never 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
curl "https://api.radokpay.com/v1/collections?limit=2" \
  -H "Authorization: Bearer $RADOK_SECRET_KEY"
200 OK
{
  "data": [
    { "reference": "col_8b2e...", "status": "succeeded" },
    { "reference": "col_3a7d...", "status": "awaiting_payment" }
  ],
  "meta": {
    "nextCursor": "652f1a9c4d7b3e0812345678",
    "hasMore": true
  }
}
Walking every page — Node.js
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

It is currently a record id, but that is an implementation detail. Pass it back unmodified and do not parse, sort, or compare cursors.

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
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 only amount.value is 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

If you generate the key in memory and the process dies mid-request, the retry after restart carries a new key and creates a second payout. Write it alongside the payout record you are about to create, then send.

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

  1. GET requests are always safe to retry.
  2. POST /v1/payouts is safe to retry with the same Idempotency-Key.
  3. Other POST requests are not idempotent. On a timeout, list the resource first to check whether it was created before sending again.
  4. Back off exponentially and cap the attempts. On a 429, wait at least Retry-After seconds.