Response structure
The JSON envelope, list format, error shape and identifiers shared by every endpoint.
Every endpoint answers with the same JSON envelope, successes and failures alike. Three keys are always present, so one response handler covers the whole API and you only branch on the parts that change.
The three keys you always get
code repeats the HTTP status in the body, for clients that cannot easily read headers. status
is the HTTP reason phrase for that code, OK, Created, Not Found, Unprocessable Content, and
never the literal string error. message is a short sentence describing what happened, meant for
your logs rather than for your customers.
Everything else in the body depends on what you asked for.
One resource
When you fetch or create a single object, it arrives under a key named after its type. A payment
comes back as transaction, a customer as customer, a transfer as transfer. There is no
generic data wrapper to unwrap.
A field with no value is not always sent as null. Payments, customers, refunds, beneficiaries
and disputes leave it out of the object; transfers, invoices, links and accounts send it as
null. Treat every optional field as possibly absent and possibly null, and read it with a
default.
An endpoint may add its own top-level keys next to the resource when they are not properties of it.
Creating a payment is the case you will meet first: the hosted page URL describes the session you
just opened, not the payment itself, so it sits beside it rather than inside it. The token has no
type prefix: a random string, preceded by test_ in the sandbox.
Several resources
List endpoints break the naming rule on purpose. The array is called items, whatever it
contains, and a meta object carries the paging state. Writing one list helper that works across
payments, customers, transfers and the other resources is therefore worth doing once. The one
exception is GET /events: its array is called events, and it pages by number only.
meta comes in two shapes, and which one you get depends on how you asked. By default you page by
number and meta tells you where you are in the total. On the lists that support cursors it also
carries next_cursor and has_more. Send that next_cursor back as the cursor parameter and
the API switches to cursor paging, which is what you want for a large export or a backfill because
it stays correct while new objects arrive.
Page size defaults to 25 and caps at 100. Pagination covers how to walk a collection from end to end.
Errors
An error keeps the same three keys. The HTTP code is in the 4xx or 5xx range, status is that
code's reason phrase, and message says what went wrong. Because the envelope does not change, a
failed call parses with the same code that parses a successful one.
Two error classes add a field worth handling explicitly. A 422 carries errors, an object keyed
by field name whose values are arrays of messages, so a field can fail several rules at once; a
refusal that concerns no single field puts its message under errors.request. The top-level
message is the first of them, followed by (and 1 more error) when there are more. A
rate-limited response carries retry_after, the number of seconds to wait, which saves you
guessing a backoff.
A 409 Conflict carrying errors.idempotency_key means you reused an Idempotency-Key whose
first request is still running: send it again a moment later to get the stored response. A
reference is never checked for uniqueness, so reusing one is not a conflict. The other 409s
say the resource is not in a state that allows the call: a charge already in progress on the
payment (error_code: payment_in_progress), a prepared payment that was activated, cancelled or
has expired, a payment whose instalments were partly paid, and a few Sync and CLI cases their pages
cover. Waiting does not change those: read the resource, then decide.
A 404 tells you which resource was missing, Payment Not Found rather than a generic message, so
the message field is enough to route the error without inspecting the URL you called. The full
catalogue of codes lives in the API reference.
Do not parse the message
message is prose and it changes. Branch on code, and on the keys inside errors when you
need field-level detail. A few server-side failures return code and status with no message
at all, so read it defensively.
Payment statuses
A transaction's status is one of nine values. The transitions between them, and which ones are
terminal, are in Key Concepts.
| Status | Meaning |
|---|---|
pending | Created, waiting for the customer to act. |
processing | Confirmed with the provider, settlement in progress. |
succeeded | Funds received. Safe to fulfil. |
failed | Declined by the provider or the wallet. |
cancelled | Cancelled by the customer or by you. |
expired | The payment window closed before completion. |
partial | Part of the amount was captured, on a split payment. |
refunded | Fully refunded after succeeding. |
partially_refunded | Partly refunded after succeeding. |
The last two are read from API version 2026-10-11. On an earlier version a refunded payment keeps
reading succeeded.
Identifiers
Every object carries an id built the same way: a prefix naming the type, then test_ when the
object lives in the sandbox, then a random string. The prefix tells you what you are holding
without a lookup, and the test_ segment makes a sandbox id impossible to mistake for a live one
in a log or a support ticket.
| Prefix | Object | Random part |
|---|---|---|
trx_ | Payment | 20 characters |
po_ | Transfer, outgoing | 20 characters |
rfd_ | Refund | 20 characters |
dsp_ | Dispute | 20 characters |
ben_ | Beneficiary | 20 characters |
acc_ | Sync subaccount | 20 characters |
cus_ | Customer | 24 characters |
evt_ | Webhook event | 24 characters |
So a sandbox payment reads trx_test_CSUGajfv9xh0XQ5wu2lx and its production twin
trx_CSUGajfv9xh0XQ5wu2lx. Store ids as text of unbounded length, never in a fixed 24 or 32
character column. Invoices and payment links follow their own rules, listed in
Key Concepts.
Dates
Every timestamp is ISO 8601 in UTC, with an explicit offset rather than a Z suffix.
"created_at": "2026-05-24T10:21:08+00:00"Both spellings mean the same instant and any ISO 8601 parser handles them, but if you compare timestamps as strings, which you should not, that offset is the form you will actually receive.
Amounts
Amounts are expressed in the major unit of the currency. 5000 in a response means 5 000 XAF, not
50 XAF, which is the opposite of what most payment APIs do. The CFA franc has no subunit in daily
use, so a minor-unit convention would be inventing a denomination that does not exist.
Values come back as numbers rounded to two decimals. What you can charge is bounded per currency:
from 25 to 2 000 000 in XAF and XOF, from 100 in NGN, from 0.50 in USD and EUR. An amount outside
those bounds is refused with a 422 whose message states them. Read the currency field before
doing arithmetic, since the same figure means very different things in XAF and in USD.