Skip to content

Errors & Status Codes

Every error response shares one envelope. Understanding it — particularly the difference between 300 and 400 — is the single most important thing for a reliable integration.


The error envelope

{
  "code": 400,
  "error": [
    {
      "msg": "Human readable message",
      "path": "field_name"
    }
  ]
}
  • error is always an array, even for a single problem.
  • error[].msg — human readable, safe to log.
  • error[].path — the field or subsystem that failed. Common values: token, key, auth, user, phone, amount, network, planId, meterNo, smartCardNo, type, wallet, kyc, kyc_required, permission, biller, domain, server, agent, origin, reference.
  • code — mirrors the HTTP status on service-thrown errors. See the caveat below.

A validation failure adds the offending value and its location:

{
  "error": [
    {
      "value": "",
      "msg": "Amount is required",
      "path": "amount",
      "location": "body"
    }
  ]
}

Status codes

HTTP code Meaning Retry? Money
200 200 Success — Debited
300 300 Transient — still being verified, or fixable by the caller Yes, after waiting Debited (held)
400 400 Permanent failure — provider declined or request invalid Safe to retry with a fix Refunded
401 — Missing or invalid credential No Not debited
403 — Authenticated but not permitted No Not debited
404 — Not found No Not debited
500 — Unexpected server error Yes, with backoff Refunded (guaranteed)
503 — Dependency unavailable (Redis/SSO) Yes, with backoff Not debited

The 300 vs 400 rule

This is the rule that matters:

300 means "not yet" — the outcome is unknown or the request needs correcting. 400 means "no" — the transaction will not succeed as sent.

A 300 on a purchase means the provider did not confirm delivery yet. The platform is already polling the provider on your behalf. Retrying immediately is the wrong move — it risks a double charge, and the platform's duplicate guard will likely reject it anyway.

A 400 on a purchase means the provider declined, or the request was structurally wrong. The wallet has been refunded.

What actually produces which code

300 — transient:

  • Input validation failure (the express-validator chain).
  • Insufficient wallet balance.
  • Service or channel disabled by a kill switch.
  • Phone number belongs to a different network than the one you named.
  • KYC not completed (path: "kyc" or path: "kyc_required").
  • Minimum or maximum amount exceeded.
  • A duplicate identical request was detected within the guard window.
  • The provider returned a pending/uncertain verdict.
  • The provider's network is in a bad state (but the purchase can be scheduled).
  • The biller is flagged unhealthy.

400 — permanent:

  • The provider declined the transaction.
  • The biller integration was retired ("Provider integration removed; manual reconciliation required").
  • The wallet debit succeeded but the provider call failed in a way that indicates the transaction did not happen.

403 — not permitted:

  • Wrong credential, wrong role, or wrong channel for the route.
  • Suspended or inactive account.
  • Blocked IP, device fingerprint or user-agent.
  • Wrong Origin on a developer-portal route.
  • Not an approved agent.
  • Transaction blocked for a phone number on the telephone blacklist.

500 — unexpected:

  • Unhandled error. The purchase path guarantees a wallet refund on any unhandled exception, and the message explicitly says so.
  • Missing User-Agent (a client error surfaced as a server error — see Authentication).

A caveat on the code field

code is not always identical to the HTTP status.

ErrorResponse — the helper used by most middleware — hardcodes code: 400 regardless of the HTTP status it sets:

// Middlewares/ClassExports.cjs
export const ErrorResponse = (res, status, msg, path, array, object) => {
  // ...
  return res.status(status).json({ code: 400, error: [{ msg, path }] });
};

So a permission failure arrives as:

{
  "code": 400,
  "error": [{ "msg": "Permission denied", "path": "permission" }]
}

…on an HTTP 300.

The rule to follow: branch on the HTTP status, not on code. The body code is only reliable on the purchase endpoints, where the six-argument ErrorResponse form is used and the body code is set to the real status:

{
  "code": 300,
  "reference": "IP|TN|A|123|20260918120000_ASDFG",
  "error": [{ "path": "airtime-merrybills", "msg": "..." }]
}

BaseController.sendError does the same — it reflects the thrown status:

if (error?.status && Array.isArray(error?.errors)) {
  return res.status(error.status).json({ code: error.status, error: error.errors });
}

Purchase error responses carry a reference

Purchase failures include the transaction reference, so you can look the attempt up:

{
  "code": 400,
  "reference": "IP|TN|A|123|20260918120000_ASDFG",
  "error": [
    {
      "path": "airtime-merrybills",
      "msg": "Insufficient balance on your Merrybills account"
    }
  ]
}

The path names the provider that produced the error, not a request field. Provider names in the path are internal routing details — do not parse them.


Scheduling response

When a network is in a bad state, the API offers to schedule the purchase instead of failing outright:

{
  "code": 300,
  "canSchedule": true,
  "action": "schedule",
  "error": [
    {
      "path": "network_bad",
      "msg": "The network for MTN is currently in a bad state, but you can schedule the purchase and we will complete it once the network recovers."
    }
  ]
}

canSchedule: true means the platform will deliver this purchase later without any further action from you — you do not need to resubmit it.


Retry guidance

Situation Retry? Delay
400 purchase failure Yes, after fixing the cause Immediate
300 pending purchase No Wait for the webhook, or poll resolve after 60 s
300 validation failure Yes, after fixing the field Immediate
300 insufficient funds Yes, after funding Immediate
300 duplicate guard Yes Wait for the stated window (typically 2–3 min)
403 No Fix the credential or role
401 No Refresh the credential
500 Yes Exponential backoff, 3 attempts max
503 Yes Backoff, then retry

The platform also enforces a 1 request per 5 seconds purchase limit on session-authenticated channels, and a duplicate-request guard on the API channel. See Rate Limits.


Sanitised provider errors

Provider messages are passed through a sanitiser before they reach you, so internal identifiers, credentials and stack traces are stripped. What you get is a message safe to display to a customer, plus a classification in utils/classify-purchase-error.js:

Class Meaning
network Connection refused / DNS failure
timeout The provider did not answer in time
db_unavailable Database unavailable
biller_5xx Provider returned a server error
biller_4xx Provider returned a client error
type_error The provider returned an unexpected payload
unknown Unclassified

A 500 on the purchase path also carries an explicit sentence telling the customer whether their wallet was refunded, so you can pass it through verbatim.


Unhandled routes

An unknown path returns HTTP 300:

{
  "error": [
    { "msg": "Route not found", "param": "server" }
  ]
}

Note the key is param, not path, and there is no code field. This is the catch-all handler registered last in Server.js, after every route and after the MCP mount.