Handling errors
Every error is JSON with a detail field, and the HTTP status tells you
whether retrying could ever help.
{ "detail": "Insufficient balance. Need KES 45.00, have KES 12.50." }Status codes
| Code | Meaning | Retry? |
|---|---|---|
200 / 201 | It worked | — |
202 | Accepted for delivery and charged. Not yet delivered | — |
400 | The request was understood but not allowed in this state | No, fix it |
401 | The API key is missing, wrong, or the account is suspended | No |
402 | Not enough wallet balance. Nothing was sent or charged | After topping up |
403 | Authenticated, but not permitted to do this | No |
404 | No such message, campaign or contact — or not yours | No |
409 | It already exists | No |
422 | The request body didn’t validate | No, fix it |
429 | Too many requests | Yes, after a pause |
5xx | Something broke on our side | Yes, with backoff |
Never retry a 402 automatically in a loop. You’ll hammer the API
without sending anything. Top up, then retry.
The ones you’ll actually hit
401 Invalid or missing credentials
The key is wrong or absent. Check you’re sending it as X-API-Key, not
Authorization:
curl https://api.wakalisms.com/wallet -H "X-API-Key: YOUR_KEY"If the key was rotated, the old one stopped working immediately. A
401 Account suspended is different: the key is fine, the account is
blocked. Contact support.
402 Insufficient balance
{ "detail": "Insufficient balance. Need KES 45.00, have KES 12.50." }Nothing was sent and nothing was charged. The message tells you the shortfall, so you can surface it or alert yourself. Watch your balance rather than waiting for this:
curl https://api.wakalisms.com/wallet -H "X-API-Key: YOUR_KEY"422 No valid phone numbers to send to
Every number in the request failed to parse. See
Phone numbers. If only some fail, you get a 202
and the failures come back in invalid_recipients — check that field, because
a partial failure still looks like success:
const { recipients_count, invalid_recipients } = await send(numbers, text);
if (invalid_recipients.length) {
console.warn("Not sent to:", invalid_recipients);
}422 from validation
The body didn’t match what the endpoint expects. FastAPI returns an array here, not a string:
{
"detail": [
{ "loc": ["body", "message"], "msg": "Field required", "type": "missing" }
]
}Handle both shapes when you display errors:
function errorText(body) {
const d = body?.detail;
if (typeof d === "string") return d;
if (Array.isArray(d)) return d.map(e => e.msg).join("; ");
return "Something went wrong";
}429 Too many requests
Slow down and retry with backoff. Batching helps more than retrying:
recipients takes up to 10,000 numbers, so one call beats a thousand.
Retrying safely
Retry 429 and 5xx. Never retry 4xx — the result won’t change.
async function withRetry(fn, { attempts = 4 } = {}) {
let wait = 1000;
for (let i = 0; i < attempts; i++) {
const res = await fn();
if (res.ok) return res;
// A refusal we caused won't be fixed by asking again.
if (res.status >= 400 && res.status < 500 && res.status !== 429) return res;
if (i < attempts - 1) {
await new Promise(r => setTimeout(r, wait + Math.random() * 500));
wait *= 2;
}
}
}A timeout is not a failure. If a /sms/send request times out, the
message may well have been accepted and charged. Retrying blindly sends it
twice. Check /sms/messages?phone=... before resending.
Partial success
/sms/send returns 202 when any recipient was accepted. Always read the
response rather than trusting the status:
{
"recipients_count": 48,
"cost_kes": 21.6,
"status": "sent",
"invalid_recipients": ["0202345678", "not-a-number"]
}And status can be failed inside a 202 if the carrier rejected the whole
batch — in which case you were refunded automatically:
if (data.status === "failed") {
// Already refunded. Safe to try again.
console.error(data.error);
}Getting help
Include the job_id or message id and roughly when it happened —
support@wakalisms.com. That’s enough for us to
find the exact message in our logs.