Skip to content
Loading keys…

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

CodeMeaning
200Success.
201Resource created.
202Accepted. The payment or transfer is in flight at the provider; wait for the webhook.
400The 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.
401Missing, unknown, revoked or expired key.
402POST /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.
403Scope 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.
404Resource not found.
405Wrong HTTP method for that path.
406A private or restricted key is required and a public key was sent.
409The 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.
422Validation failed, or the request was refused as it stands. errors is always present; see error_code on a refused payout.
429Rate limit exceeded.
500Internal error on our side.
501Endpoint exists but is not available in this environment, currently only POST /identity/resolve in live.
502The 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.

Response · 404 Not Found
{
"code": 404,
"status": "Not Found",
"message": "Payment Not Found"
}

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.

Response · 422 Unprocessable Content
{
"code": 422,
"status": "Unprocessable Content",
"message": "The amount field must be at least 100. (and 1 more error)",
"errors": {
"amount": [
"The amount field must be at least 100."
],
"customer.email": [
"The customer.email field must be a valid email address."
]
}
}
errorsobjectoptional
Field name to array of messages, or request for a refusal that names no field. Present on every 422 and on the 409 of an Idempotency-Key still being processed.
messagestringoptional
The first message, followed by (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.

Response · 403 Forbidden
{
"code": 403,
"status": "Forbidden",
"message": "Payments from this country are not accepted by this merchant.",
"error_code": "country_restricted"
}
error_codeReturned byMeaning
merchant_restrictedAny call, POST /payments/{id}, POST /transfers/batchAPI access, payments or bulk payouts are closed for the account. Contact support.
payment_method_restrictedPOST /payments/{id}This payment method, Mobile Money, or international cards are turned off for the account.
country_restrictedPOST /payments/{id}The payer's country is refused: domestic payments only, CEMAC or UEMOA only, or a blocked country.
currency_restrictedPOST /payments/{id}Only the account's own currency is accepted.
feature_not_in_plan/accounts, any call carrying X-Sync, /taxSync 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.

Response · 403 Forbidden
{
"code": 403,
"status": "Forbidden",
"error_code": "shield_blocked",
"message": "Payment blocked: transaction risk score too high. Please contact support and reference BLK-7K2Q9XWD.",
"block_reference": "BLK-7K2Q9XWD"
}

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.

Response · 422 Unprocessable Content
{
"code": 422,
"status": "Unprocessable Content",
"error_code": "daily_payout_limit_exceeded",
"message": "This payout would exceed the account's daily payout limit.",
"errors": {
"amount": [
"This payout would exceed the account's daily payout limit."
]
}
}
error_codeWhat refused the payoutWhat to do
amount_out_of_rangeThe amount is outside the minimum and maximum for that currency.Change the amount.
amount_above_channel_limitThe amount is above the operator's per-transfer limit.Split it into several payouts.
beneficiary_not_foundNo 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_inactiveThe beneficiary is deactivated.Reactivate it, or pay another beneficiary.
beneficiary_not_allowedThe beneficiary was added from the Dashboard and cannot be paid through the API.Pay it from the Dashboard.
beneficiary_missing_payment_methodThe beneficiary has no payout method, or its method has no channel.Fix the beneficiary.
beneficiary_missing_phone, beneficiary_missing_account_number, beneficiary_missing_walletThe method lacks the number, account or wallet address its channel needs.Fix the beneficiary.
beneficiary_not_verifiedThe destination has not been verified yet.Verify it, then retry.
beneficiary_cooling_offThe destination was added or changed moments ago.Retry once its payout_allowed_after has passed.
channel_not_supportedThe channel is not one a payout can use.Use Mobile Money, a Djamo wallet, a Wajub account or a payout account.
currency_not_supportedThe currency is unknown or inactive.Use a supported currency.
idempotency_key_reusedThe Idempotency-Key was already used for another payout, or on another connection.Use a new key for a new payout.
payouts_suspendedPayouts are suspended across Wajub.Retry later.
merchant_restrictedPayouts are restricted on your account.Contact support.
negative_balanceA balance of yours, in any currency, is below zero.Wait for collections to cover it.
reserve_heldThe 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_exceededYour 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_exceededThe platform ceiling for your verification tier.Wait for the period to end, or complete a higher tier.
payouts_not_enabledPayouts are not available on your account yet.Complete verification.
too_many_payouts, hourly_payout_amount_exceededThe hourly ceiling on payout calls, or on the amount paid out.Retry after the hour.
no_providerNo payout route exists for that channel and currency right now.Retry later, or use another channel.
insufficient_balanceavailable does not cover the amount and the fee.Top up, then retry.
channel_not_available_in_sandbox, invalid_test_number, invalid_test_account, insufficient_fundsSandbox 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:

StatusExceptionExtra property
401AuthenticationException
403PermissionException
404NotFoundException
400, 422InvalidRequestExceptionerrors
429RateLimitExceptionretryAfter
anything elseWajubErrorhttpStatus, raw
networkApiConnectionException

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.

What did you think of this content?