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, on a charge or a cancel (payment_in_progress), a cancel of a payment with instalments paid (payment_partly_paid), 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, or an event resent less than 5 minutes ago to the same endpoints (event_resend_cooldown, not a throttle). |
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.
A path that encodes a slash or a backslash, %2F or %5C in any case, answers this 404 whatever
the key. Put each id in the path as it is; the query string is not concerned.
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 | Too many payouts, or too large an amount paid out, in a short time. | Retry later. |
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 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), and processing_error or payout_queue_busy on a line that could not be sent
this time: 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 transient failures on its own, honouring Retry-After. 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.