Error Handling
SendComms uses conventional HTTP response codes to indicate the success or failure of an API request. Codes in the 2xx range indicate success, codes in the 4xx range indicate client errors, and codes in the 5xx range indicate server errors. Every error carries a stable machine-readable code and a docs_url that links straight back to this page.
Error Response Structure
All error responses follow a consistent structure. Any extra context an error carries is flattened alongside code and message — it is never nested under a details object.
{
"success": false, // always false on an error
"error": {
"code": "ERROR_CODE", // machine-readable, stable
"message": "Human-readable description",
"transaction_id": "sms_...", // when the request reached a transaction
"docs_url": "https://docs.sendcomms.com/docs/errors#client-error-codes"
}
}Fields you can rely on
codeandmessageare present on every error.docs_urlis present on every error. Codes documented on this page link directly to their section anchor; anything else links to the top of this page.transaction_id(orbatch_id) appears once a transaction record exists — log it, it is what support needs to trace the request.error_idappears on unexpected 503s and identifies the recorded incident on our side.- Context fields vary by code:
requiredandcurrencyon balance errors,limit/remaining/reset/retryAfteron rate limits,errorson validation failures, andprovider_messageon declined purchases.
HTTP Status Codes
| Code | Status | Description |
|---|---|---|
| 200 | OK | Request succeeded |
| 201 | Created | Data or airtime purchase created |
| 400 | Bad Request | Invalid parameters, or the provider rejected this specific message |
| 401 | Unauthorized | Invalid or missing API key |
| 402 | Payment Required | Insufficient account balance |
| 403 | Forbidden | Account suspended |
| 404 | Not Found | Transaction or resource not found |
| 409 | Conflict | A request with the same idempotency_key is still in progress |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Unexpected server error |
| 502 | Bad Gateway | Could not reach the upstream data or airtime provider |
| 503 | Service Unavailable | Service temporarily unavailable or not configured |
Client Error Codes
These errors mean something about the request needs to change. Retrying an identical request will fail identically, so fix the request first. All validation runs before any balance is reserved or any rate-limit or idempotency state is consumed — a rejected request never costs you anything.
Authentication and access
Raised before the request body is even read.
| Status | Code | When it happens | How to fix it |
|---|---|---|---|
| 401 | UNAUTHORIZED | The Authorization header is missing or malformed, or the key is unknown, revoked or inactive. | Send Authorization: Bearer <your_api_key> (the legacy X-API-Key header also works) and confirm the key is still active in your dashboard. |
| 403 | ACCOUNT_SUSPENDED | The API key is valid but the account it belongs to has been deactivated. | Contact support@sendcomms.com. Requests keep failing until the account is reactivated. |
| 403 | FORBIDDEN | The API key is valid, but it does not carry the permission for the service you called. The response names the needed scope in required_permission and the key’s current scopes in key_permissions. | Use a key that has the service enabled, or create a new key with that permission from the dashboard. |
| 403 | SENDER_ID_NOT_YOURS | The alphanumeric sender ID passed as "from" is registered to another SendComms account. Sender IDs are globally unique so one customer cannot send under another business’s brand. | Register your own sender ID under Sender IDs, omit "from" to use the default sender, or contact support@sendcomms.com if you own the brand. |
| 409 | CONFLICT | A domain you tried to add is already held by another SendComms account, is already on your account, or is already configured on our mail infrastructure. The response carries a reason field: taken, taken_by_you or in_use_upstream. Creating a mailbox on a domain that has not been verified yet also returns 409. | Call GET /api/v1/domains/check?name=… first. If you own a domain that shows as taken, contact support@sendcomms.com. For mailboxes, verify the domain before creating addresses on it. |
Request format
The body could not be parsed, or is missing something the endpoint requires.
| Status | Code | When it happens | How to fix it |
|---|---|---|---|
| 400 | INVALID_JSON | The request body could not be parsed as JSON at all (empty body, trailing comma, form encoding). | Send a JSON body with Content-Type: application/json. Serialise with your language’s JSON encoder rather than string concatenation. |
| 400 | INVALID_REQUEST | The body is valid JSON but not the shape the endpoint expects — a field has the wrong type, or the body is not a JSON object. | Read the errors array included in the error object: it lists the offending field path and the expected type. |
| 400 | MISSING_FIELD | A required field is absent — to, message, subject, phone_number, network, capacity_gb, or the transaction_id query parameter on status endpoints. In /email/batch the message names the offending index. | Add the field named in the message. The message is always of the form "Missing required field: <name>". |
| 400 | MISSING_CONTENT | An email was submitted with neither html nor text content. | Provide at least one of html or text. Sending both is recommended so clients that block HTML still render the message. |
| 400 | NO_RECIPIENTS | The to field is present but resolves to an empty list. | Pass a non-empty string or a non-empty array of addresses in to. |
| 400 | TOO_MANY_RECIPIENTS | More than 50 recipients on a single email. /email/send counts to; the mail service also rejects any message whose to + cc + bcc together exceed 50. | Split the send, or use POST /api/v1/email/batch to submit up to 100 separate messages in one call. |
| 400 | BATCH_LIMIT_EXCEEDED | POST /api/v1/email/batch was called with more than 100 emails in the emails array. | Chunk the array into batches of 100 or fewer and send them sequentially. |
| 400 | INVALID_INPUT | POST /api/v1/email/batch was called without an emails array, or with an empty one. | Send { "emails": [ ... ] } with at least one message object. |
| 404 | NOT_FOUND | A status lookup referenced a transaction_id that does not exist, or does not belong to your account. | Use the transaction_id returned by the original send/purchase response. IDs are scoped to the account that created them. |
Field validation
The request is well formed, but a value is not something SendComms can send.
| Status | Code | When it happens | How to fix it |
|---|---|---|---|
| 400 | INVALID_EMAIL | An address in to, cc or bcc is not a valid email address. The message repeats the address, and names the field for cc/bcc. | Validate addresses before sending. Display names must use the Name <user@example.com> form. |
| 400 | UNVERIFIED_SENDER_DOMAIN | The domain in the from address is not a verified sending domain on your account. The error echoes provided_from and provided_domain. | Verify the domain under Domains in your dashboard, or omit from entirely to send from your account default sender. SendComms never silently substitutes a different sender. |
| 400 | INVALID_PHONE_NUMBER | The SMS or airtime recipient is not a valid E.164 number. | Send the number in E.164 format, including the + and country code — for example +233540800994. |
| 400 | MESSAGE_TOO_LONG | An SMS body is longer than 1600 characters (10 concatenated segments). | Shorten the message, or split it into several sends. Non-GSM characters shorten each segment, so long unicode messages hit the limit sooner. |
| 400 | INVALID_CONTINENT | The optional continent routing hint on /sms/send is not one of the supported values. | Use one of the values listed in the error message, or omit continent and let SendComms detect it from the number. |
| 400 | INVALID_PHONE | The number on a Ghana data purchase is not a valid Ghanaian mobile number. | Use 0241234567 or +233241234567. Data bundles are Ghana-only, so international numbers are rejected here. |
| 400 | INVALID_NETWORK | The network on a data purchase is not a supported Ghanaian carrier. | Use mtn, telecel or airteltigo (case-insensitive). |
| 400 | INVALID_AMOUNT | The airtime amount is missing, zero, negative or not numeric. | Send amount as a positive number in the operator’s currency. |
| 400 | INVALID_OPERATOR | The airtime operatorId could not be read as a number. | Pass the numeric operator id from the operator lookup endpoint, not the operator name. |
| 400 | INVALID_COUNTRY | The optional airtime countryCode is not a two-letter ISO code. | Use a 2-letter ISO 3166-1 alpha-2 code such as GH or NG, or omit the field. |
| 400 | INVALID_URL | Webhook registration was sent without a url, or with a url that is not HTTPS. | Register an absolute https:// endpoint. Plain HTTP endpoints are rejected. |
| 400 | INVALID_EVENTS | Webhook registration was sent with an empty events array, or with event names SendComms does not emit. | Send a non-empty array of supported event names — the error message lists every valid value — or use "*" to subscribe to all of them. |
| 400 | MISSING_ID | DELETE /api/v1/webhooks was called without the id query parameter. | Append ?id=<webhook_id>, using the id returned when the webhook was registered. |
Billing, limits and concurrency
Your request is valid, but your account state or request rate blocks it right now.
| Status | Code | When it happens | How to fix it |
|---|---|---|---|
| 402 | INSUFFICIENT_BALANCE | A prepaid account does not have enough balance to cover the request. Balance is reserved before the provider is called, so nothing is sent. | Top up in the dashboard. The error carries required and currency so you can alert on the shortfall. |
| 429 | RATE_LIMIT_EXCEEDED | You exceeded a per-minute, per-hour, per-day or per-month limit for your plan, either globally or for one service. | Back off and retry after retryAfter seconds. The response also carries Retry-After and X-RateLimit-* headers. |
| 409 | REQUEST_IN_PROGRESS | Another request with the same idempotency_key is still in flight for this account. | Wait and retry the same key — once the first request finishes, replaying the key returns its stored response with X-Idempotent-Replay: true. Use a fresh key for a genuinely new message. |
Examples
UNAUTHORIZED{
"success": false,
"error": {
"code": "UNAUTHORIZED",
"message": "Invalid or missing API key",
"docs_url": "https://docs.sendcomms.com/docs/errors#client-error-codes"
}
}MISSING_FIELD{
"success": false,
"error": {
"code": "MISSING_FIELD",
"message": "Missing required field: to",
"docs_url": "https://docs.sendcomms.com/docs/errors#client-error-codes"
}
}INVALID_REQUEST{
"success": false,
"error": {
"code": "INVALID_REQUEST",
"message": "Invalid request body",
"errors": [
{
"type": "string_type",
"loc": ["message"],
"msg": "Input should be a valid string"
}
],
"docs_url": "https://docs.sendcomms.com/docs/errors#client-error-codes"
}
}UNVERIFIED_SENDER_DOMAIN{
"success": false,
"error": {
"code": "UNVERIFIED_SENDER_DOMAIN",
"message": "The \"from\" domain \"example.com\" is not verified for your account. Verify it under Domains, or omit \"from\" to use your default sender.",
"provided_from": "Acme <hello@example.com>",
"provided_domain": "example.com",
"docs_url": "https://docs.sendcomms.com/docs/errors#client-error-codes"
}
}INSUFFICIENT_BALANCE{
"success": false,
"error": {
"code": "INSUFFICIENT_BALANCE",
"message": "Insufficient balance. Please add funds to your account.",
"required": 0.035,
"currency": "USD",
"docs_url": "https://docs.sendcomms.com/docs/errors#client-error-codes"
}
}RATE_LIMIT_EXCEEDED{
"success": false,
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Rate limit exceeded. Please try again later.",
"limit": 5,
"remaining": 0,
"reset": 1766452500,
"retryAfter": 39,
"docs_url": "https://docs.sendcomms.com/docs/errors#client-error-codes"
}
}REQUEST_IN_PROGRESS{
"success": false,
"error": {
"code": "REQUEST_IN_PROGRESS",
"message": "Request is being processed. Please wait.",
"docs_url": "https://docs.sendcomms.com/docs/errors"
}
}SMS_SEND_FAILED{
"success": false,
"error": {
"code": "SMS_SEND_FAILED",
"message": "Failed to send SMS. Please try again in a few minutes.",
"transaction_id": "sms_mfa1x2y_9c4d1e2f3a4b",
"docs_url": "https://docs.sendcomms.com/docs/errors#service-error-codes"
}
}Service Error Codes
These errors come from the carriers, mail infrastructure and top-up providers behind SendComms, or from SendComms itself. When a send or purchase fails, any reserved balance is refunded and the idempotency lock is released before the error is returned — so the same key can be retried safely.
| Status | Code | Service | Description | Retryable |
|---|---|---|---|---|
400503 | SMS_SEND_FAILED | SMS | 400 when the carrier rejected this specific message (unverified sender ID, blocked or opted-out recipient) — the message explains what to change. 503 when the carrier itself is failing. | On 503 |
400503 | EMAIL_SEND_FAILED | 400 when the mail service rejected the message and returned a reason. 503 when the mail service is unreachable or erroring. | On 503 | |
400503 | PURCHASE_FAILED | Data / Airtime | The provider declined the purchase. 400 carries provider_message with the exact reason (out of stock, invalid bundle, operator rejected). 503 means the provider is degraded. | On 503 |
503 | DATA_PURCHASE_FAILED | Data | An unexpected failure inside the data purchase flow. The reservation is refunded and the idempotency lock released before the error is returned. | Yes |
503 | AIRTIME_PURCHASE_FAILED | Airtime | An unexpected failure inside the airtime purchase flow. The reservation is refunded and the idempotency lock released before the error is returned. | Yes |
502 | PROVIDER_ERROR | Data / Airtime | SendComms could not reach the upstream provider at all (connection failure or timeout). Nothing was charged. | Yes |
503 | SERVICE_UNAVAILABLE | All | The requested service is not currently available on this deployment — for example the email, data or airtime provider is not configured. | No |
500 | INTERNAL_ERROR | All | An unhandled error. The message is always "Internal server error" — details are logged on our side, never returned. | Yes |
Reading a 400 vs a 503 on the same code
SMS_SEND_FAILED, EMAIL_SEND_FAILED and PURCHASE_FAILED use the HTTP status to tell you what to do. A 400 means the provider rejected this particular message and the message text says why — retrying unchanged will fail again. A 503 means the provider is having trouble: wait a few seconds and retry, reusing the same idempotency_key so you cannot double-send.
Best Practices
Check the Error Code
Use the error.code field for programmatic handling rather than parsing error messages. Message wording can change; codes do not.
Use Idempotency Keys for Retries
Always include an idempotency_key when retrying failed requests to prevent duplicate processing. A retry that arrives while the first attempt is still running returns REQUEST_IN_PROGRESS (409); one that arrives after it finished replays the original response with an X-Idempotent-Replay: true header.
Implement Exponential Backoff
For rate limit (429), bad gateway (502) and service unavailable (503) errors, implement exponential backoff starting with a 1-second delay and doubling each retry. On a 429, prefer the Retry-After header when it is present.
Log Transaction IDs
Always log the transaction_id, batch_id or error_id from error responses. This helps support quickly identify and resolve issues.
Follow the docs_url
Every error includes a docs_url pointing at the section of this page that explains it. Surfacing that link in your own logs and dashboards saves your team the lookup.
Retry Example
Here's how to implement safe retries with idempotency:
async function sendSMSWithRetry(to, message, maxRetries = 3) {
const idempotencyKey = crypto.randomUUID();
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const response = await fetch('https://api.sendcomms.com/api/v1/sms/send', {
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({ to, message, idempotency_key: idempotencyKey })
});
const data = await response.json();
if (response.ok) return data; // Success
const code = data.error?.code;
// 409: the first attempt is still running - wait, then retry the SAME key
// 429/502/503: transient - back off and retry
const retryable =
code === 'REQUEST_IN_PROGRESS' ||
response.status === 429 ||
response.status === 502 ||
response.status === 503;
// Anything else (validation, balance, unverified sender) will fail
// identically on retry - fix the request instead.
if (!retryable || attempt === maxRetries) {
throw new Error(`${code}: ${data.error?.message} (${data.error?.docs_url})`);
}
const retryAfter = Number(response.headers.get('Retry-After'));
const delay = retryAfter ? retryAfter * 1000 : Math.pow(2, attempt) * 1000;
await new Promise(r => setTimeout(r, delay));
}
}