Error Handling
HTTP status codes, error response format, and retry strategy
Response shape
Every error returns a message field:
{ "message": "Insufficient balance" }Validation failures (422) add an errors object keyed by field name:
{
"message": "The receiver must be valid in system providers.",
"errors": {
"receiver": ["The receiver must be valid in system providers."]
}
}There is no machine-readable error code. Branch on the HTTP status, and on message only to separate two failures that share a status.
Status codes
| Status | When | Retry |
|---|---|---|
400 | A business rule rejected the request — insufficient balance, provider mismatch, unknown contact group, missing template parameter | No |
401 | Token missing, invalid, or expired; project inactive; IP not whitelisted; OTP code wrong or expired | No |
403 | Contact-group send from a company that is not yet verified | No |
404 | Sender, template, contact group, OTP, or message not found | No |
409 | Idempotency-Key reused with a different request body | No |
422 | Request validation failed — carries an errors object | No |
429 | Rate limited, or OTP attempts exhausted | Only with Retry-After |
503 | The send could not be accepted right now | Yes, with backoff |
Insufficient balance is 400, not 402. Invalid input is 422, not 400.
Error messages
Match on the status and the message — several statuses cover more than one failure.
| Status | message | What to do |
|---|---|---|
400 | Insufficient balance | Top up the wallet, or check the project's subscription quota |
400 | The receiver is not from the same provider | The recipient's network is not one your sender ID is registered on. Send from a sender ID that covers it |
400 | The sender and receivers are not compatible. | No recipient in the batch is on a network your sender ID covers |
401 | Invalid or missing token. | Check the Authorization header, and reissue the token if it was rotated |
401 | Project is inactive. | Re-enable the project from the dashboard |
401 | Unauthorized IP address. | Add the calling IP to the project's whitelist |
404 | Sender not found | The sender ID is unknown, not yet approved, expired, or not linked to your company |
409 | Idempotency key is already in use | Use a new key, or resend the identical body to get the original result back |
429 | Too many OTP requests. Please try again later. | Wait out Retry-After |
429 | Too many invalid OTP attempts. Request a new OTP. | Do not retry — request a new OTP |
503 | The provider for this receiver is temporarily unavailable | Retry later. Sends to other networks are unaffected |
503 | The provider for these receivers is temporarily unavailable | Every remaining recipient is on that one network. Retry the batch later |
503 | SMS ingest unavailable | Retry with backoff |
Two statuses worth special handling
429
The two rate limits look alike and behave oppositely.
| Endpoint | Cause | Retry-After | Retry |
|---|---|---|---|
| Initiate OTP | More than 10 OTPs requested for this number in an hour | Yes | After the stated delay |
| Verify OTP | All 3 attempts for this request_id are used | No | Never — the OTP is dead, request a new one |
If you handle 429 in one place, use the presence of Retry-After to tell them apart.
503
Neither kind charges your balance, but they clear on different timescales.
message | Affects | Back off |
|---|---|---|
SMS ingest unavailable | All sends | Seconds |
The provider for … is temporarily unavailable | Only recipients on that one network | Minutes to hours |
During the second kind, sends to every other network keep succeeding. Queue the affected recipients and retry them rather than pausing all traffic.
Numbers on an unreachable network remain valid. Keep them in your contact lists — only sending to them fails.
Retrying safely
Retry 503 and 5xx with exponential backoff. Retry 429 only when Retry-After is present, and honour that delay. Never retry 400, 401, 403, 404, 409, or 422 — they fail identically on every attempt.
Send the same Idempotency-Key on every attempt so a request that succeeded before a timeout is not sent twice. Reusing a key with a different body returns 409.
async function sendSms(payload, idempotencyKey, attempts = 4) {
for (let attempt = 0; attempt < attempts; attempt++) {
const response = await fetch('https://sms.lamah.com/api/sms/messages', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.ERSAAL_API_TOKEN}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(payload),
});
if (response.ok) return response.json();
const { message } = await response.json();
const retryAfter = response.headers.get('Retry-After');
const retryable =
response.status >= 500 || (response.status === 429 && retryAfter);
if (!retryable || attempt === attempts - 1) {
throw new Error(`${response.status}: ${message}`);
}
const delay = retryAfter ? Number(retryAfter) * 1000 : 2 ** attempt * 1000;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}import time
import requests
def send_sms(payload, idempotency_key, attempts=4):
for attempt in range(attempts):
response = requests.post(
'https://sms.lamah.com/api/sms/messages',
headers={
'Authorization': f'Bearer {API_TOKEN}',
'Accept': 'application/json',
'Idempotency-Key': idempotency_key,
},
json=payload,
)
if response.ok:
return response.json()
retry_after = response.headers.get('Retry-After')
retryable = response.status_code >= 500 or (
response.status_code == 429 and retry_after
)
if not retryable or attempt == attempts - 1:
raise Exception(f"{response.status_code}: {response.json()['message']}")
time.sleep(float(retry_after) if retry_after else 2 ** attempt)