Errors
Status codes, the errors object, and how to branch on a failure.
Wajub uses conventional HTTP status codes: 2xx succeeded, 4xx is something about your
request, 5xx is something about ours. Every error carries the same envelope as a success, plus
an errors object when a field is at fault.
Status codes
| Code | Meaning |
|---|---|
200 | Success. |
201 | Resource created. |
202 | Accepted. The payment or transfer is in flight at the provider; wait for the webhook. |
400 | The request is well formed but the resource is not in a state that allows it (a payment that is no longer pending, for example), or an API version that is not supported. |
401 | Missing, unknown, revoked or expired key. |
402 | POST /payments/{id} could not charge: the channel declined (error_code gives the reason), the account cannot take live payments yet (merchant_not_verified), or a transaction limit is reached (limit_exceeded). The payment goes back to pending. |
403 | Scope missing, IP not allow-listed, private key used from a browser, a live-only feature called with a sandbox key, a restriction on the account, or a payment Shield blocked. |
404 | Resource not found. |
405 | Wrong HTTP method for that path. |
406 | A private or restricted key is required and a public key was sent. |
409 | The request conflicts with one in flight or with the resource's state: an Idempotency-Key still being processed (the message is under errors.idempotency_key), a charge already in progress (payment_in_progress), a prepared payment that can no longer change or be activated, a Sync connection in a state that refuses the call. A reference is never checked for uniqueness. |
422 | Validation failed, or the request was refused as it stands. errors is always present; see error_code on a refused payout. |
429 | Rate limit exceeded. |
500 | Internal error on our side. |
501 | Endpoint exists but is not available in this environment, currently only POST /identity/resolve in live. |
502 | The payment provider refused or could not be reached. |
Error shape
status is always the HTTP reason phrase, never the literal string error. message is one
sentence meant for your logs, not for a switch.
The 404 message names the resource: Payment Not Found, Customer Not Found,
Transfer Not Found, Invoice Not Found, Link Not Found, Beneficiary Not Found,
Account Not Found, and Resource Not Found for anything else.
Validation errors
A 422 adds an errors object mapping each rejected field to its list of messages. Nested
fields use dot notation, matching the path in your request body. Every 422 carries it: a refusal
that concerns no single field puts its message under errors.request.
errorsobjectoptionalrequest for a refusal that names no field. Present on every 422 and on the 409 of an Idempotency-Key still being processed.messagestringoptional(and 1 more error) or (and N more errors) when several fields failed. Read errors for the full list.Two non-validation failures also arrive as a 422 on the idempotency_key field: a malformed
Idempotency-Key header, and a key reused with a different payload. See
Idempotency.
Restriction errors
Wajub can restrict an account, for example after a risk review. A call that a restriction refuses
answers 403 with an error_code: branch on error_code, and keep message for your logs.
error_code | Returned by | Meaning |
|---|---|---|
merchant_restricted | Any call, POST /payments/{id}, POST /transfers/batch | API access, payments or bulk payouts are closed for the account. Contact support. |
payment_method_restricted | POST /payments/{id} | This payment method, Mobile Money, or international cards are turned off for the account. |
country_restricted | POST /payments/{id} | The payer's country is refused: domestic payments only, CEMAC or UEMOA only, or a blocked country. |
currency_restricted | POST /payments/{id} | Only the account's own currency is accepted. |
feature_not_in_plan | /accounts, any call carrying X-Sync, /tax | Sync or Tax is not included in your plan and no add-on is active. |
A payment refused this way is not charged and stays pending. When the whole API is closed, the
403 also carries a restriction field with the restriction type. A transaction limit reached on
POST /payments/{id} (single, daily or monthly) is not a restriction: it answers 402 with
error_code: limit_exceeded, as does merchant_not_verified for an account that cannot take live
payments yet.
Shield blocks
A payment Shield blocks answers 403 with error_code: shield_blocked, and
block_reference carries the reference to quote to support. No provider was contacted, nothing
was charged, and the payment stays pending.
Payout refusals
A payout refused before it exists answers 422 with an error_code next to message and
errors. Branch on the code. The same codes come back on each refused line of
POST /transfers/batch, in results[].error_code, and on POST /accounts/{id}/payouts.
error_code | What refused the payout | What to do |
|---|---|---|
amount_out_of_range | The amount is outside the minimum and maximum for that currency. | Change the amount. |
amount_above_channel_limit | The amount is above the operator's per-transfer limit. | Split it into several payouts. |
beneficiary_not_found | No beneficiary of yours has that id. A batch line also gets it when it names the beneficiary by phone or e-mail. | Check the ben_ id. Do not retry as is. |
beneficiary_inactive | The beneficiary is deactivated. | Reactivate it, or pay another beneficiary. |
beneficiary_not_allowed | The beneficiary was added from the Dashboard and cannot be paid through the API. | Pay it from the Dashboard. |
beneficiary_missing_payment_method | The beneficiary has no payout method, or its method has no channel. | Fix the beneficiary. |
beneficiary_missing_phone, beneficiary_missing_account_number, beneficiary_missing_wallet | The method lacks the number, account or wallet address its channel needs. | Fix the beneficiary. |
beneficiary_not_verified | The destination has not been verified yet. | Verify it, then retry. |
beneficiary_cooling_off | The destination was added or changed moments ago. | Retry once its payout_allowed_after has passed. |
channel_not_supported | The channel is not one a payout can use. | Use Mobile Money, a Djamo wallet, a Wajub account or a payout account. |
currency_not_supported | The currency is unknown or inactive. | Use a supported currency. |
idempotency_key_reused | The Idempotency-Key was already used for another payout, or on another connection. | Use a new key for a new payout. |
payouts_suspended | Payouts are suspended across Wajub. | Retry later. |
merchant_restricted | Payouts are restricted on your account. | Contact support. |
negative_balance | A balance of yours, in any currency, is below zero. | Wait for collections to cover it. |
reserve_held | The amount would dip into the reserve held against refunds. | Pay out less, or wait for the reserve to be released. |
daily_count_limit_exceeded, daily_amount_limit_exceeded | Your own daily cap, set in Transfer security settings. | Raise your cap, or wait until tomorrow. |
single_payout_limit_exceeded, daily_payout_limit_exceeded, monthly_payout_limit_exceeded | The platform ceiling for your verification tier. | Wait for the period to end, or complete a higher tier. |
payouts_not_enabled | Payouts are not available on your account yet. | Complete verification. |
too_many_payouts, hourly_payout_amount_exceeded | The hourly ceiling on payout calls, or on the amount paid out. | Retry after the hour. |
no_provider | No payout route exists for that channel and currency right now. | Retry later, or use another channel. |
insufficient_balance | available does not cover the amount and the fee. | Top up, then retry. |
channel_not_available_in_sandbox, invalid_test_number, invalid_test_account, insufficient_funds | Sandbox only: a bank transfer sent by hand, or a test number or account that picks a refusal. | See Test scenarios. |
POST /transfers/batch adds three of its own: idempotency_key_required (400, the batch has no
Idempotency-Key), reference_reused_in_batch (a line reuses a reference of this batch for a
different payout), processing_error on a line that failed on our side, and payout_queue_busy on
a line that waited too long behind another live payout of the account: those two are safe to retry
as is. A single POST /transfers in that last case answers 429 with error_code: payout_queue_busy and Retry-After. POST /accounts/{id}/payouts adds account_not_lite,
account_not_active, account_not_connected and payout_destination_missing (422),
live_mode_only (400) and nothing_to_pay_out (409).
A request that is malformed, such as a missing field, answers the usual 422 without an
error_code: the errors object names the field.
Every response has a request ID
X-Request-Id is set on every response, including errors. Log it next to your own correlation
ID, and quote it when you open a support ticket. You can also send your own value, up to 64
characters, and it is echoed back instead of being generated.
curl https://api.wajub.com/payments/trx_CSUGajfv9xh0XQ5wu2lx \
-H "Authorization: $WAJUB_API_KEY" \
-H "X-Request-Id: order-4172-attempt-1"Branching on a failure
Branch on the status code, never on message. Three families deserve different treatment: a
422 is a bug in your payload and retrying will not help, a 429 is a timing problem and the
response tells you how long to wait, a 5xx or 502 is transient and worth a bounded retry.
const res = await fetch(url, { method: 'POST', headers, body });
if (res.status === 422) {
const { errors } = await res.json();
return showFieldErrors(errors);
}
if (res.status === 429) {
const wait = Number(res.headers.get('retry-after') ?? 1);
return retryAfter(wait * 1000);
}
if (!res.ok) {
throw new Error(`Wajub ${res.status} ${res.headers.get('x-request-id')}`);
}The PHP SDK turns each status code into a dedicated exception, so a catch block is enough to
separate the three families:
| Status | Exception | Extra property |
|---|---|---|
401 | AuthenticationException | |
403 | PermissionException | |
404 | NotFoundException | |
400, 422 | InvalidRequestException | errors |
429 | RateLimitException | retryAfter |
| anything else | WajubError | httpStatus, raw |
| network | ApiConnectionException |
Every one of them exposes errorCode, httpStatus, errors and raw, the decoded response
body.
Retries are already built in
The SDK retries 429, 500, 502, 503 and 504 twice on its own, honouring Retry-After
and otherwise backing off exponentially with jitter. By the time an exception reaches you, the
call has already been given its chances.
Read the failed request back
Any request, including the ones that failed, stays visible in Konsole → API Logs: the body you sent, the exact response, and the provider involved.