Skip to content

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.

A validation failure
{
"code": 422,
"status": "Unprocessable Content",
"message": "The given data was invalid.",
"errors": {
"amount": [
"The amount must be at least 0.01."
],
"customer.email": [
"The customer.email field must be a valid email address."
]
}
}

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.

CodeIt meansDo
400The request is well formed, the resource is in the wrong stateRead the state, do not retry
401The key is unknown, revoked, inactive or past expires_atFix the credential
402The payer's money did not moveShow the reason, offer another channel
403A restricted key without the scope, or an IP outside the key's allow-listFix the key, not the call
404No such resource for this key's team and environmentCheck the id, and the environment
405Wrong verb on a real pathFix the call
406The route needs a private key and you sent a public oneMove the call to your server
422A field is invalid, or an idempotency key conflictsFix the payload, do not retry
429A ceiling trippedBack off, see Rate limiting
500Wajub's faultRetry 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'

Only three things are worth retrying

FailureRetryWhy
429Yes, after the delay the response gives youThe ceiling resets
5xxYes, with back-offWajub's problem, usually brief
Timeout or dropped connectionYes, with the same idempotency keyYou do not know whether it landed
4xx other than 429NeverThe 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.

Retry with exponential back-off and jitter
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.

The same policy, without writing it
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.

FieldForWhat to do with it
error_codeYour codeBranch on it, store it, count it. Stable and translatable
payer_messageThe payerShow it as is, it is already written for them
messageYour logsThe 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.

What did you think of this content?