OI Payments Docs
Core concepts

Responses & errors

The single response envelope every endpoint returns, pagination, and the full error-code catalogue.

Every endpoint — success or failure — returns the same JSON envelope.

The envelope

{
  "data": { "...": "the payload (or null)" },
  "meta": {
    "success": true,
    "message": "optional human-readable message",
    "errorCode": null,
    "timestamp": "2026-06-04T10:00:00Z"
  },
  "pagination": null
}
FieldMeaning
dataThe payload on success. For list endpoints, this is the page's items.
meta.successBoolean outcome flag.
meta.messageOptional narrative (e.g. "Resource created" or an error description).
meta.errorCodeStable machine-readable code on failure (see below); null on success.
meta.timestampServer UTC instant.
paginationPresent only on list endpoints.

Branch on meta.errorCode, not on meta.message — messages are for humans and may change; codes are part of the contract and never change meaning.

Pagination

List endpoints put the items in data and the page info in pagination:

{
  "data": ["...items..."],
  "meta": { "success": true, "timestamp": "2026-06-04T10:00:00Z" },
  "pagination": { "page": 0, "size": 20, "totalElements": 137, "totalPages": 7 }
}

Pages are zero-indexed. For the full set of query parameters — page, size, sortBy, order (ASC/DESC), and per-endpoint filters — and how pagination is computed, see Pagination & filtering.

Error codes

Errors return the envelope with meta.success = false and a meta.errorCode. The HTTP status reflects the category.

errorCodeHTTPMeaning
VALIDATION_ERROR400Request failed validation; data holds a field → message map.
UNAUTHORIZED401Missing/invalid credentials.
TOKEN_EXPIRED / SESSION_EXPIRED401Admin session no longer valid.
REAUTH_REQUIRED403Admin must re-authenticate before a sensitive action.
INVALID_CREDENTIALS401Bad email/password (admin login).
FORBIDDEN403Authenticated but not permitted.
RESOURCE_NOT_FOUND404No such resource in your scope.
RESOURCE_ALREADY_EXISTS409Uniqueness conflict.
INVALID_OPERATION_STATE422The resource isn't in a state that allows the action.
IDEMPOTENCY_KEY_CONFLICT409Same idempotency key, different body.
IDEMPOTENCY_IN_PROGRESS409Same idempotency key still processing.
LAST_SUPER_ADMIN_PROTECTED409Cannot disable the last Super Admin.
LEDGER_ALREADY_REVERSED422A ledger entry was asked to be reversed twice.
ACCOUNT_LOCKED423Admin account is locked.
ACCOUNT_DISABLED403Admin account is disabled.
WEAK_PASSWORD400Password does not meet policy.
PAYMENT_GATEWAY_ERROR502The gateway was unreachable or rejected the operation.
LEDGER_UNBALANCED422Ledger invariant breach (debits ≠ credits).
INTERNAL_ERROR500Unexpected server error.

Validation errors

A 400 VALIDATION_ERROR puts the per-field messages in data:

{
  "data": { "amountMinor": "must be greater than 0" },
  "meta": { "success": false, "errorCode": "VALIDATION_ERROR",
            "message": "Validation failed", "timestamp": "…" }
}

Malformed or oversized JSON bodies

A request body that is not parseable as JSON — syntactically broken, oversized, or too deeply nested (for example a metadata payload that trips the hardened JSON read limits) — never reaches validation. It returns 400 VALIDATION_ERROR with a generic message and no per-field map (data is null):

{
  "data": null,
  "meta": { "success": false, "errorCode": "VALIDATION_ERROR",
            "message": "Malformed JSON request body", "timestamp": "…" }
}

Branch on meta.errorCode to tell the two 400 cases apart: a field-level rejection carries a data map of field → message, while an unparseable body has data: null. The raw parser detail is never echoed back — it could leak fragments of your body — so the message stays generic.

Uniqueness and integrity conflicts

A database integrity violation — for example two requests racing to create the same unique resource, or any insert/update that breaks a unique constraint — surfaces as 409 RESOURCE_ALREADY_EXISTS with a generic message:

{
  "data": null,
  "meta": { "success": false, "errorCode": "RESOURCE_ALREADY_EXISTS",
            "message": "Resource conflict", "timestamp": "…" }
}

This is the same code a deliberate uniqueness check returns, so treat RESOURCE_ALREADY_EXISTS as "this resource already exists" regardless of which layer caught it. For repeated create-style mutations, prefer an idempotency key so a retry replays the original response instead of racing into a conflict.

On this page