Skip to content

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.

GET /payments/{id} · 200 OK
{
"code": 200,
"status": "OK",
"message": "Payment retrieved",
"transaction": {
"id": "trx_test_CSUGajfv9xh0XQ5wu2lx",
"reference": "order-4172",
"status": "succeeded",
"amount": 5000,
"currency": "XAF",
"created_at": "2026-05-24T10:21:08+00:00"
}
}

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.

POST /payments · 201 Created
{
"code": 201,
"status": "Created",
"message": "Payment initiated",
"authorization_url": "https://pay.wajub.com/test_7Yh2Mp4kQ9xL4vR8tB3nW6yC1dF5sJ0aG…",
"authorization_token": "test_7Yh2Mp4kQ9xL4vR8tB3nW6yC1dF5sJ0aG…",
"transaction": {
"id": "trx_test_CSUGajfv9xh0XQ5wu2lx",
"status": "pending",
"amount": 25000,
"currency": "XAF"
}
}

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.

GET /payments · 200 OK
{
"code": 200,
"status": "OK",
"message": "Payments retrieved",
"items": [
{
"id": "trx_test_CSUGajfv9xh0XQ5wu2lx",
"status": "succeeded",
"amount": 5000
},
{
"id": "trx_test_9Wm4Kd2pR7vB5nL6hY1c",
"status": "pending",
"amount": 12000
}
],
"meta": {
"current_page": 1,
"last_page": 4,
"per_page": 25,
"total": 87,
"next_cursor": "Q2t9mVxL4pRw8nZs...",
"has_more": true
}
}

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.

meta, cursor mode
{
"per_page": 25,
"next_cursor": "Hj4kP9wQx2LmV7tRb3NcY6sD",
"prev_cursor": null,
"has_more": true
}

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.

422 Unprocessable Content
{
"code": 422,
"status": "Unprocessable Content",
"message": "The amount field is required. (and 1 more error)",
"errors": {
"amount": [
"The amount field is required."
],
"currency": [
"The selected currency is invalid."
]
}
}

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.

Payment statuses

A transaction's status is one of nine values. The transitions between them, and which ones are terminal, are in Key Concepts.

StatusMeaning
pendingCreated, waiting for the customer to act.
processingConfirmed with the provider, settlement in progress.
succeededFunds received. Safe to fulfil.
failedDeclined by the provider or the wallet.
cancelledCancelled by the customer or by you.
expiredThe payment window closed before completion.
partialPart of the amount was captured, on a split payment.
refundedFully refunded after succeeding.
partially_refundedPartly 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.

PrefixObjectRandom part
trx_Payment20 characters
po_Transfer, outgoing20 characters
rfd_Refund20 characters
dsp_Dispute20 characters
ben_Beneficiary20 characters
acc_Sync subaccount20 characters
cus_Customer24 characters
evt_Webhook event24 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.

Timestamp format
"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.

What did you think of this content?