Skip to content

Error Codes

This page documents how SIPSTACK API errors are shaped and which machine-readable codes your client can branch on.

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"
}
  • message is human-readable — this is the field to display.
  • error carries the same string as message. It is a legacy field kept for older clients; prefer message.
  • code is present only when there is a machine-readable code (see the table below) — always check the HTTP status first and treat code as a refinement.
  • data is 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 statusCode field in the body. Read the status from the HTTP status line — response.status, not body.statusCode.
  • Validation failures return 400 with the validation messages joined into message (no code).
  • On a 5xx, the body is deliberately sparse. message/error are a generic “Internal server error”, data is always omitted, and code is 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 a 5xx. Do not rely on code or data being present on a 5xx; branch on the HTTP status. 4xx responses are unaffected — they carry their documented code/data unchanged.

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.

StatusMeaningTypical causes
400Bad requestValidation failure, malformed body, invalid phone number format
401UnauthorizedMissing/expired session token, missing/invalid API key
402Payment requiredThe 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)
403ForbiddenInsufficient role/permission, plan feature not enabled, suspended account, or an SMS would go beyond your allowance with no valid payment method (CARD_NOT_VERIFIED)
404Not foundResource ID doesn’t exist or isn’t in your organization
409ConflictDuplicate resource (e.g. extension number already in use)
412Precondition failedPending agreement acceptance (partner endpoints)
422Unprocessable entityValidation error on the Developer API (e.g. a message references unsupported merge variables on send/campaign create)
429Too many requestsRequest rate limit, or an SMS plan limit (PAID_LIMIT_REACHED, TRIAL_LIMIT_REACHED) — check Retry-After; see Rate Limits
500Server errorUnexpected failure — retry with backoff; contact support if persistent
503Service unavailableAn SMS send limit couldn’t be checked right now — retry after a short backoff

These code values are stable and intended for client branching:

CodeHTTPMeaningAction
AUTH_REQUIRED401No credentials presentedAuthenticate
TOKEN_EXPIRED401Session JWT past its expiryLog in again
TOKEN_INVALID401Token malformed or signature invalidLog in again
TOKEN_REVOKED401Session revoked (sign-out everywhere, password change)Log in again
SESSION_EXPIRED401Server-side session no longer existsLog in again
FORBIDDEN403Authenticated but not permittedCheck role / permissions
SUBSCRIPTION_REQUIRED402Endpoint requires an active product subscriptionSubscribe to the product
BILLING_REQUIRED402Billing problem blocks the operationResolve billing
MFA_STEP_UP_REQUIRED403Sensitive action needs fresh two-factor verificationPrompt for a TOTP code, verify, retry
insufficient_scope403Developer API key lacks the scope the endpoint requires — enforced fail-closed, so a key holding neither the required scope nor * satisfies no scoped endpointMint or rotate a key holding the required scope (or *)
NOVA_REGION_UNAVAILABLE409No PBX capacity in your data-residency regionExpected condition — try later
REQUIRES_PARTNER_AGREEMENT_ACCEPTANCE412Partner agreement updated since last acceptanceAccept the current agreement
UNSUPPORTED_MERGE_VARS422The Developer API send/campaign message references merge variables outside the supported setUse only supported merge variables in the template
TOO_MANY_MEDIA400More 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_LARGE400An uploaded MMS file, or the total of all uploaded files, exceeds 3,500,000 bytes (3.5 MB) — the carrier’s ceilingCompress or resize before sending; the total across a message is capped too
MEDIA_TYPE_NOT_ALLOWED400Declared upload type is not image/jpeg, image/png or image/gifConvert to an accepted type — video/audio MMS and image/webp are not accepted
MEDIA_TYPE_MISMATCH400An uploaded file’s bytes are not what its declared Content-Type claimsDeclare the file’s real type — SIPSTACK never relabels or transcodes
MEDIA_MALFORMED400Empty upload, invalid base64, or a malformed mediaFiles entryCheck the encoding and that data is present and non-empty
PAID_LIMIT_REACHED429An SMS send limit was reached; reason is daily, monthly_absolute, or new_account_velocity. See SMS Send LimitsWait for Retry-After — don’t retry in a loop
TRIAL_LIMIT_REACHED429 / 503On 429, a trial SMS limit was reached (reason: lifetime or burst). On 503, the trial limit couldn’t be checkedlifetime: activate your plan. burst: slow down. 503: retry shortly
CARD_NOT_VERIFIED403The SMS would go beyond your included allowance and no valid payment method is on fileAdd or update a card on Account → Billing
CAPABILITY_RESTRICTED_BY_STATE402SMS beyond your included allowance is paused while the subscription is past due or scheduled to cancelPay the open invoice or undo the cancellation
CONTACTS_IMPORT_NO_VALID_ROWS400A 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 whyFix the listed rows (and make sure the file has a phone column), then import again
CONTACTS_IMPORT_EMPTY_FILE400A contact import was refused because the CSV held no contact rows — it was empty, had only a header, or every phone cell was blankChoose a CSV with at least one row that has a phone number
CONTACTS_IMPORT_NO_INPUT400A contact import request carried neither a CSV file nor a non-empty contacts arraySend the file (or the array) with the request. From Switchboard, try the upload again, and contact support if it keeps failing
OPT_OUT_REASON_REQUIRED400POST /api/v2/contacts/{id}/opt-in without a reason for a contact who opted out by texting STOP. Nothing was changedSend the request again with a reason explaining how the contact gave permission again

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.

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}`);
}
}

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.