إرسال

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

StatusWhenRetry
400A business rule rejected the request — insufficient balance, provider mismatch, unknown contact group, missing template parameterNo
401Token missing, invalid, or expired; project inactive; IP not whitelisted; OTP code wrong or expiredNo
403Contact-group send from a company that is not yet verifiedNo
404Sender, template, contact group, OTP, or message not foundNo
409Idempotency-Key reused with a different request bodyNo
422Request validation failed — carries an errors objectNo
429Rate limited, or OTP attempts exhaustedOnly with Retry-After
503The send could not be accepted right nowYes, 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.

StatusmessageWhat to do
400Insufficient balanceTop up the wallet, or check the project's subscription quota
400The receiver is not from the same providerThe recipient's network is not one your sender ID is registered on. Send from a sender ID that covers it
400The sender and receivers are not compatible.No recipient in the batch is on a network your sender ID covers
401Invalid or missing token.Check the Authorization header, and reissue the token if it was rotated
401Project is inactive.Re-enable the project from the dashboard
401Unauthorized IP address.Add the calling IP to the project's whitelist
404Sender not foundThe sender ID is unknown, not yet approved, expired, or not linked to your company
409Idempotency key is already in useUse a new key, or resend the identical body to get the original result back
429Too many OTP requests. Please try again later.Wait out Retry-After
429Too many invalid OTP attempts. Request a new OTP.Do not retry — request a new OTP
503The provider for this receiver is temporarily unavailableRetry later. Sends to other networks are unaffected
503The provider for these receivers is temporarily unavailableEvery remaining recipient is on that one network. Retry the batch later
503SMS ingest unavailableRetry with backoff

Two statuses worth special handling

429

The two rate limits look alike and behave oppositely.

EndpointCauseRetry-AfterRetry
Initiate OTPMore than 10 OTPs requested for this number in an hourYesAfter the stated delay
Verify OTPAll 3 attempts for this request_id are usedNoNever — 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.

messageAffectsBack off
SMS ingest unavailableAll sendsSeconds
The provider for … is temporarily unavailableOnly recipients on that one networkMinutes 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)

On this page