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¶
erroris 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:
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:
300means "not yet" — the outcome is unknown or the request needs correcting.400means "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-validatorchain). - 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"orpath: "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
Originon 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:
…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:
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.