Error Codes
Error Codes
Section titled “Error Codes”This page documents how SIPSTACK API errors are shaped and which machine-readable codes your client can branch on.
Error Response Formats
Section titled “Error Response Formats”Errors come in two shapes depending on which endpoint produced them.
Central error handler (most portal endpoints):
{ "error": "Token expired", "message": "Token expired", "code": "TOKEN_EXPIRED"}messageis human-readable — this is the field to display.errorcarries the same string asmessage. It is a legacy field kept for older clients; prefermessage.codeis present only when there is a machine-readable code (see the table below) — always check the HTTP status first and treatcodeas a refinement.datais present only on the errors that carry structured detail (for example validation blockers, or the fields needed to render a subscription upsell). Treat it as optional.- There is no
statusCodefield in the body. Read the status from the HTTP status line —response.status, notbody.statusCode. - Validation failures return
400with the validation messages joined intomessage(nocode). - On a
5xx, the body is deliberately sparse.message/errorare a generic “Internal server error”,datais always omitted, andcodeis preserved only for the small, curated token/session-refresh family —TOKEN_EXPIRED,TOKEN_INVALID,TOKEN_REVOKED,SESSION_EXPIRED— since each of those maps to exactly one client action (re-authenticate) and carries no other diagnostic detail. Every other code, including the rest of the table below, is withheld on a5xx. Do not rely oncodeordatabeing present on a5xx; branch on the HTTP status. 4xx responses are unaffected — they carry their documentedcode/dataunchanged.
Inline errors (the messaging API and some endpoints):
{ "success": false, "error": "Recipient phone number (to) is required"}Here error is a human-readable string. Branch on the HTTP status.
Some inline errors also carry a code — notably the MMS media validation failures on POST /api/v2/messages/send ({ "success": false, "code": "MEDIA_TOO_LARGE", "error": "..." }) and the refusals of the contact import (POST …/contacts/import), which are in the table below. Treat code as an optional refinement on this shape too.
HTTP Status Codes
Section titled “HTTP Status Codes”| Status | Meaning | Typical causes |
|---|---|---|
| 400 | Bad request | Validation failure, malformed body, invalid phone number format |
| 401 | Unauthorized | Missing/expired session token, missing/invalid API key |
| 402 | Payment required | The endpoint’s product isn’t on your subscription (SUBSCRIPTION_REQUIRED), billing must be set up or brought up to date first (BILLING_REQUIRED), or an SMS would go beyond your allowance while the subscription is past due or cancelling (CAPABILITY_RESTRICTED_BY_STATE) |
| 403 | Forbidden | Insufficient role/permission, plan feature not enabled, suspended account, or an SMS would go beyond your allowance with no valid payment method (CARD_NOT_VERIFIED) |
| 404 | Not found | Resource ID doesn’t exist or isn’t in your organization |
| 409 | Conflict | Duplicate resource (e.g. extension number already in use) |
| 412 | Precondition failed | Pending agreement acceptance (partner endpoints) |
| 422 | Unprocessable entity | Validation error on the Developer API (e.g. a message references unsupported merge variables on send/campaign create) |
| 429 | Too many requests | Request rate limit, or an SMS plan limit (PAID_LIMIT_REACHED, TRIAL_LIMIT_REACHED) — check Retry-After; see Rate Limits |
| 500 | Server error | Unexpected failure — retry with backoff; contact support if persistent |
| 503 | Service unavailable | An SMS send limit couldn’t be checked right now — retry after a short backoff |
Machine-Readable Codes
Section titled “Machine-Readable Codes”These code values are stable and intended for client branching:
| Code | HTTP | Meaning | Action |
|---|---|---|---|
AUTH_REQUIRED | 401 | No credentials presented | Authenticate |
TOKEN_EXPIRED | 401 | Session JWT past its expiry | Log in again |
TOKEN_INVALID | 401 | Token malformed or signature invalid | Log in again |
TOKEN_REVOKED | 401 | Session revoked (sign-out everywhere, password change) | Log in again |
SESSION_EXPIRED | 401 | Server-side session no longer exists | Log in again |
FORBIDDEN | 403 | Authenticated but not permitted | Check role / permissions |
SUBSCRIPTION_REQUIRED | 402 | Endpoint requires an active product subscription | Subscribe to the product |
BILLING_REQUIRED | 402 | Billing problem blocks the operation | Resolve billing |
MFA_STEP_UP_REQUIRED | 403 | Sensitive action needs fresh two-factor verification | Prompt for a TOTP code, verify, retry |
insufficient_scope | 403 | Developer API key lacks the scope the endpoint requires — enforced fail-closed, so a key holding neither the required scope nor * satisfies no scoped endpoint | Mint or rotate a key holding the required scope (or *) |
NOVA_REGION_UNAVAILABLE | 409 | No PBX capacity in your data-residency region | Expected condition — try later |
REQUIRES_PARTNER_AGREEMENT_ACCEPTANCE | 412 | Partner agreement updated since last acceptance | Accept the current agreement |
UNSUPPORTED_MERGE_VARS | 422 | The Developer API send/campaign message references merge variables outside the supported set | Use only supported merge variables in the template |
TOO_MANY_MEDIA | 400 | More than 10 MMS media parts on one message (hosted URLs and uploads combined) | Send at most 10 media parts, or split across messages |
MEDIA_TOO_LARGE | 400 | An uploaded MMS file, or the total of all uploaded files, exceeds 3,500,000 bytes (3.5 MB) — the carrier’s ceiling | Compress or resize before sending; the total across a message is capped too |
MEDIA_TYPE_NOT_ALLOWED | 400 | Declared upload type is not image/jpeg, image/png or image/gif | Convert to an accepted type — video/audio MMS and image/webp are not accepted |
MEDIA_TYPE_MISMATCH | 400 | An uploaded file’s bytes are not what its declared Content-Type claims | Declare the file’s real type — SIPSTACK never relabels or transcodes |
MEDIA_MALFORMED | 400 | Empty upload, invalid base64, or a malformed mediaFiles entry | Check the encoding and that data is present and non-empty |
PAID_LIMIT_REACHED | 429 | An SMS send limit was reached; reason is daily, monthly_absolute, or new_account_velocity. See SMS Send Limits | Wait for Retry-After — don’t retry in a loop |
TRIAL_LIMIT_REACHED | 429 / 503 | On 429, a trial SMS limit was reached (reason: lifetime or burst). On 503, the trial limit couldn’t be checked | lifetime: activate your plan. burst: slow down. 503: retry shortly |
CARD_NOT_VERIFIED | 403 | The SMS would go beyond your included allowance and no valid payment method is on file | Add or update a card on Account → Billing |
CAPABILITY_RESTRICTED_BY_STATE | 402 | SMS beyond your included allowance is paused while the subscription is past due or scheduled to cancel | Pay the open invoice or undo the cancellation |
CONTACTS_IMPORT_NO_VALID_ROWS | 400 | A contact import was refused because every row was rejected — a phone number that isn’t a valid number, an invalid consent / consent_date / consent_method value, or a CSV with no recognised phone column. Nothing was imported. data.errors lists the first 20 rejected rows and why | Fix the listed rows (and make sure the file has a phone column), then import again |
CONTACTS_IMPORT_EMPTY_FILE | 400 | A contact import was refused because the CSV held no contact rows — it was empty, had only a header, or every phone cell was blank | Choose a CSV with at least one row that has a phone number |
CONTACTS_IMPORT_NO_INPUT | 400 | A contact import request carried neither a CSV file nor a non-empty contacts array | Send the file (or the array) with the request. From Switchboard, try the upload again, and contact support if it keeps failing |
OPT_OUT_REASON_REQUIRED | 400 | POST /api/v2/contacts/{id}/opt-in without a reason for a contact who opted out by texting STOP. Nothing was changed | Send the request again with a reason explaining how the contact gave permission again |
SMS Send Limits
Section titled “SMS Send Limits”POST /api/v2/messages/send refuses a send that would break a plan limit with one of the four codes above, using the inline shape plus a reason where one applies. A PAID_LIMIT_REACHED response also carries a Retry-After header: the number of seconds until the limit lifts. The full table, with what to do for each, is in Rate Limits → Plan Message Limits. How the limits work: SMS Usage, Limits & Billing.
Handling Errors in Code
Section titled “Handling Errors in Code”async function callApi(url, options = {}) { const response = await fetch(url, { ...options, headers: { 'Content-Type': 'application/json', ...options.headers }, });
const body = await response.json().catch(() => ({})); if (response.ok) return body.data ?? body;
const code = body.code; const message = body.message ?? body.error ?? response.statusText;
switch (code) { case 'TOKEN_EXPIRED': case 'TOKEN_INVALID': case 'TOKEN_REVOKED': case 'SESSION_EXPIRED': await reauthenticate(); // log in again, then retry once return callApi(url, options);
case 'MFA_STEP_UP_REQUIRED': await promptForTotpAndVerify(); // POST the code, then retry return callApi(url, options);
default: if (response.status === 429) { // SMS plan limits send Retry-After (seconds until the limit lifts). // If that's far away, don't wait in-process — surface it to your queue. const retryAfter = Number(response.headers.get('Retry-After') ?? NaN); if (retryAfter > 60 || code === 'TRIAL_LIMIT_REACHED') { throw new Error(`${response.status} ${code ?? ''}: ${message}`); } const wait = Number.isFinite(retryAfter) ? retryAfter : Number(response.headers.get('RateLimit-Reset') ?? 60); await new Promise((r) => setTimeout(r, wait * 1000)); return callApi(url, options); } throw new Error(`${response.status} ${code ?? ''}: ${message}`); }}Reporting Persistent Errors
Section titled “Reporting Persistent Errors”For repeated 500s or errors you can’t explain, open a support ticket with the request path, the full response body, the time (with timezone), and the API version from the Switchboard footer.