Error handling
Which failures to retry, which to surface to the payer, and which to never repeat.
"The call failed" is not one thing. A rejected card, a malformed payload, a key without permission
and a dropped connection all arrive as an exception in the same catch block, and they want
opposite reactions. Retrying the malformed payload wastes your quota forever. Not retrying the
dropped connection loses a sale that was one second away.
This page is the sorting. The catalogue of codes lives in Errors and the catalogue of decline causes in Failure reasons.
Every error has the same shape
Whatever went wrong, the body is JSON with the same three keys, plus errors when a field is at
fault. status is the HTTP reason phrase, never the literal string error.
Nested fields use dot notation, so customer.email in the response points straight at
customer.email in what you sent.
What each code actually means here
Two of these are commonly misread, and both cost real money when they are.
| Code | It means | Do |
|---|---|---|
400 | The request is well formed, the resource is in the wrong state | Read the state, do not retry |
401 | The key is unknown, revoked, inactive or past expires_at | Fix the credential |
402 | The payer's money did not move | Show the reason, offer another channel |
403 | A restricted key without the scope, or an IP outside the key's allow-list | Fix the key, not the call |
404 | No such resource for this key's team and environment | Check the id, and the environment |
405 | Wrong verb on a real path | Fix the call |
406 | The route needs a private key and you sent a public one | Move the call to your server |
422 | A field is invalid, or an idempotency key conflicts | Fix the payload, do not retry |
429 | A ceiling tripped | Back off, see Rate limiting |
500 | Wajub's fault | Retry with back-off, alert if it persists |
The first misread is 400. It is not the validation code: a bad amount or a missing currency is
422. A 400 means something like "this payment cannot be cancelled, it already completed", so the
fix is to look at the resource, never to send the same body again.
The second is 406. A public key on /balance, /transfers, /refunds, /disputes or
/beneficiaries answers 406 Private Key Required, not 403. If you are branching on 403 to
detect a wrong key, that branch never fires.
Catching it
Every SDK raises typed errors, so you can branch on the kind rather than on a number. In Node, the
HTTP status is on httpStatus, and field errors on errors.
# Print the body, then the status on its own line
curl -s -o /tmp/body.json -w '%{http_code}\n' \
https://api.wajub.com/payments \
-H "Authorization: sk_test.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…" \
-H "Content-Type: application/json" \
-d '{ "amount": 5000, "currency": "XAF", "email": "buyer@example.com" }'
cat /tmp/body.json | jq '.code, .message, .errors'`err.status` is undefined in Node
The property is httpStatus. A retry predicate written against err.status compares undefined
to a number, is always false, and quietly retries nothing.
Only three things are worth retrying
| Failure | Retry | Why |
|---|---|---|
429 | Yes, after the delay the response gives you | The ceiling resets |
5xx | Yes, with back-off | Wajub's problem, usually brief |
| Timeout or dropped connection | Yes, with the same idempotency key | You do not know whether it landed |
4xx other than 429 | Never | The same request produces the same answer |
The third row is the one that needs the key. A timeout on POST /payments is the ambiguous case:
the payment may exist, may not, and your code cannot tell. With the same Idempotency-Key on the
retry, both outcomes converge on one payment. Without it, the retry is a second charge.
Idempotency covers how to build that key.
The SDK gives up on a request after 30 seconds by default and raises a connection error. On a checkout page, where a customer is watching a spinner, lower it.
import { WajubRateLimitError, WajubConnectionError, WajubError } from '@wajub/node';
const isRetryable = (err) =>
err instanceof WajubRateLimitError ||
err instanceof WajubConnectionError ||
(err instanceof WajubError && (err.httpStatus ?? 0) >= 500);
export async function withRetry(fn, { maxAttempts = 4, baseDelay = 500 } = {}) {
for (let attempt = 1; ; attempt++) {
try {
return await fn();
} catch (err) {
if (!isRetryable(err) || attempt >= maxAttempts) throw err;
const hinted = err instanceof WajubRateLimitError ? err.retryAfter : undefined;
const backoff = baseDelay * 2 ** (attempt - 1);
const delay = hinted != null ? hinted * 1000 : backoff * (0.5 + Math.random());
await new Promise((r) => setTimeout(r, delay));
}
}
}
const payment = await withRetry(() =>
wajub.payments.create(params, { idempotencyKey: `ORDER-${order.id}`, timeout: 8000 }),
);The jitter is not decoration. Without it, every worker that failed at the same moment retries at the same moment, and the second wave is as large as the first.
For a shell script or a cron job, cURL already implements the same policy, retrying only transient
failures and leaving 4xx alone.
curl --retry 4 --retry-delay 2 --retry-max-time 60 \
https://api.wajub.com/payments/trx_CSUGajfv9xh0XQ5wu2lx \
-H "Authorization: sk_test.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…"A decline is not an error to retry
402 Payment Required means the money did not move: no balance, a blocked wallet, a customer who
let the prompt expire. Retrying the identical charge produces the identical decline, and on some
channels it also earns the payer another notification they did not ask for.
The body carries two fields that matter, and they are for two different audiences.
| Field | For | What to do with it |
|---|---|---|
error_code | Your code | Branch on it, store it, count it. Stable and translatable |
payer_message | The payer | Show it as is, it is already written for them |
message | Your logs | The provider's raw text, always English |
Offer the customer a different channel or a different number. That is a new payment with a new idempotency key, not a retry of the old one.
Webhook failures are Wajub's retries, not yours
Your endpoint has one job before anything else: answer 200 fast. Everything that can be slow,
including your database, belongs after the acknowledgement.
Wajub allows 10 seconds per delivery and tries five times, spaced 30 seconds, 1 minute, 5 minutes,
10 minutes and 1 hour. Any non 2xx answer, and a timeout, counts as a failure and starts that
clock.
This is why a failing handler must not leak into the response. If your fulfilment throws and you
answer 500, Wajub redelivers, your handler throws again, and you have turned one bug into five
copies of it. Answer 200, put the event on a queue, and retry the queue on your own terms.
The one case that should answer non 2xx is a signature that does not verify. Return 400 and stop:
that request did not come from Wajub, and there is nothing to redeliver.
Circuit breakers, when a provider is down for hours
Back-off handles a blip. It does not handle an outage lasting longer than your queue's patience, where every job retries, fails, and retries again until the queue is nothing but failures.
A circuit breaker stops calling after a run of failures, fails fast for a cooling period, then lets a single request through to test the water. Use a proven implementation rather than writing one: opossum on Node, and the equivalent in your stack.
What matters is where you put it. Wrap the call that has a customer waiting, so checkout degrades into a clear message instead of a 30 second spinner. Leave your background reconciliation outside it, because that work is allowed to be slow and should keep trying.