Every eSMS SMS API error comes back with an HTTP status and a JSON envelope whose error.code is a stable, machine-readable string such as country_not_enabled or insufficient_balance. Branch on that code, not on the message text: this guide lists the SMS API error codes you are likely to meet, what each means, how to fix it, and which are safe to retry. Refused sends are never charged.
Last reviewed: 29 October 2026
How to handle SMS API error codes
Read the error envelope
Errors share one shape.
error.codeis what you branch on,error.messageis for people, anderror.detailscarries extra context such asrequiredandavailableon a 402. The olderdetailfield is kept for backward compatibility.HTTP/1.1 403 Forbidden X-Request-Id: 3f9a1c2b4d5e6f708192a3b4c5d6e7f8 { "error": { "code": "country_not_enabled", "message": "Sending to Cameroon (CM) is not enabled for this account yet. ...", "request_id": "3f9a1c2b4d5e6f708192a3b4c5d6e7f8", "details": { "to": "+237677123456", "country": "CM", "enable_url": "https://sms.esmsafrica.io/countries" } }, "detail": { "code": "country_not_enabled", "...": "..." } }Decide: retry or fix
Treat
429and5xxas temporary and retry with backoff. Treat other4xxcodes as something to fix: retrying the same request gives the same answer. The official SDKs already retry the temporary cases for you.Retry with the same Idempotency-Key
Send an
Idempotency-Keyheader and reuse it on every retry. If the first attempt actually went through, the retry returns the original result instead of sending and charging again. A plain HTTP example:async function sendWithPolicy(payload, idempotencyKey) { for (let attempt = 0; attempt < 4; attempt++) { const res = await fetch("https://sms.esmsafrica.io/api/messages/send", { method: "POST", headers: { "Authorization": `Bearer ${process.env.ESMS_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, // same key on every attempt }, body: JSON.stringify(payload), }); if (res.ok) return res.json(); const body = await res.json().catch(() => ({})); const code = body.error?.code; const requestId = res.headers.get("X-Request-Id"); if (res.status === 429 || res.status >= 500) { // temporary: back off, retry await new Promise(r => setTimeout(r, 1000 * 2 ** attempt)); continue; } // 4xx: retrying will not help, fix the cause throw Object.assign(new Error(body.error?.message), { status: res.status, code, requestId }); } throw new Error("Gave up after retries"); }Log the request id
Every response, success or error, carries
X-Request-Id(also inerror.request_id). Store it with the failure; it is the fastest way for support to find the exact call. You can also send your ownX-Request-Idand it is echoed back.
SMS API error codes reference
The status shown is the HTTP status the API returns for that code today.
| HTTP | Code | Meaning | Fix |
|---|---|---|---|
| 401 | unauthorized | The API key is missing, invalid or revoked (message Invalid API key or Not authenticated). | Send Authorization: Bearer esms_live_... with an active key. |
| 403 | insufficient_scope | The key lacks the permission for this call, such as send. | Create a key with the right Permissions ticked. |
| 403 | role_forbidden | The key was made by a team member whose role cannot do this. | Use a key made by someone with a suitable role. |
| 403 | account_suspended | The account is suspended or paused. | Contact support. |
| 400 | invalid_phone | The to value is not a usable number. | Send digits in international format. |
| 400 | missing_country_code | A local number such as 0712... with no country code. | Add the country code; the message suggests the right form. |
| 400 | invalid_phone_length | Too many or too few digits for that country. | Use expected_digits in details to correct it. |
| 400 | destination_unavailable | Live SMS to that country is not available yet (Coming soon or on request). Not charged. | Wait for it to open or contact sales; test keys can simulate it. |
| 403 | country_not_enabled | Live sends to that country are not switched on for your account. Not charged. | Click Enable on the Countries page. |
| 403 | country_blocked | eSMS Africa has disabled that country for your account. | Contact support. |
| 403 | dashboard_only | You tried to enable a country with an API key. | Enable countries in the dashboard (owner or admin). |
| 400 | country_needs_approval | You tried to self-enable a country outside Africa. | Contact sales to approve it. |
| 403 | sender_not_approved | The sender ID is not approved for the destination country. | Omit the sender ID or request it on the Sender IDs page. |
| 403 | recipient_opted_out | The number is on your opt-out list, for example because it replied STOP. | Do not send; remove it from the list only if the person opts back in. |
| 402 | insufficient_balance | Your balance does not cover the send. | Top up; details.required and details.available show the gap. |
| 400 | missing_merge_field | A placeholder has no value and no fallback for some recipients. Nothing sent. | Add a fallback like {{name|Customer}} or fix the data. |
| 409 | delivery_being_confirmed | You tried to retry a message we are still confirming with the network (status unknown, Confirming in the dashboard). | Wait until it shows sent or failed; a failed one is refunded automatically. |
| 409 | duplicate_message | Same text to the same number within 5 minutes, with duplicate protection on. | Expected protection; change the text or wait. |
| 409 | idempotency_in_progress | A request with the same key is still running. | Retry shortly with the same key. |
| 413 | message_too_long | The message is over 10 segments. | Shorten it. |
| 422 | validation_error | The body failed schema checks; details.errors lists the fields. | Fix the listed fields. |
| 422 | route_type_mismatch | The content does not match your account's route type (for example marketing text on an OTP-only account). | Send matching content or ask support about your route type. |
| 429 | rate_limited | Per-key limit exceeded: 20 single sends a second, 10 bulk or batch calls a minute. | Back off and retry; spread the load. |
| 429 | too_many_requests | More than 5 Verify starts for one number in an hour. | Ask the user to wait. |
| 502 | otp_send_failed | A Verify code could not be sent. Not charged. | Safe to retry. |
| 500 | internal_error | Unexpected server error. | Retry with backoff; quote the request id if it persists. |
Common errors and how to fix them
| What you see | What it means | How to fix it |
|---|---|---|
| Same 403 on every retry | 4xx errors are not temporary. | Read error.code and fix the cause instead of retrying. |
error.code is missing | You read the old detail field, which can be a plain string. | Read error.code, which every error has. |
| Retry sent a second SMS | The retry had no Idempotency-Key, or a new key each time. | Generate one key per logical send and reuse it on every attempt. |
| 402 with plenty of balance shown | The balance is in a different currency or the multi-segment message costs more than expected. | Compare details.required and details.available, and check segments. |
| Lots of 429s at peak time | You are over the per-key rate limit. | Use the bulk or batch endpoints for lists instead of looping single sends. |
Frequently asked questions
Am I charged when a send is refused?
No. Requests refused with an error are not charged, and a message that later fails at the network is refunded.
Which errors should I retry?
429 and 5xx, with backoff and the same Idempotency-Key. Fix 4xx errors instead of retrying them.
Why do I get 402 instead of 400 for low balance?
402 Payment Required is used for insufficient_balance so your code can catch it separately and prompt a top-up.
Where do failed calls show up?
Every API-key call is listed on the Request Logs page under DEVELOPERS, with its status and request id.
Related guides
Ready? Create a free eSMS account, then create a test API key and send your first simulated message in minutes. Nothing is charged until you switch to a live key.


