Skip to main content

Error Handling

SendSeven returns standard HTTP status codes with a JSON body. This guide shows the shapes you will actually see, how to read a machine-readable code when an endpoint provides one, and when to retry.

Rule of thumb: decide on the HTTP status first. Then read the body for details. Only some endpoints add a machine-readable code, and the guide for each endpoint lists the codes it returns.

Error response shapes​

1. detail as a message string​

Most errors carry a single human-readable message:

{
"detail": "Conversation not found"
}

This is the shape for most 400, 401, 403, 404 and 409 responses. Show the message to a developer or log it, but don't parse it: the wording may change.

2. detail as an object with a code​

Some endpoints return an object in detail with a stable machine-readable code and a message. The key that holds the code depends on the endpoint:

KeyExampleUsed by (examples)
error{"detail": {"error": "CHANNEL_DISCONNECTED", "message": "..."}}POST /messages and the other send endpoints (CHANNEL_DISCONNECTED, CONTACT_BLOCKED, WHATSAPP_WINDOW_EXPIRED, EMPTY_MESSAGE, ...)
code{"detail": {"code": "feature_disabled", "feature": "flows"}}Plan feature gate, campaigns (warmup_acknowledgement_required, split_plan_too_long, ...), flows, contact owner and bulk actions
error_code{"detail": {"error_code": "invalid_exclude_rule", "message": "..."}}Knowledge Base crawl rules, WhatsApp template send and edit, comments and private replies, widget identity verification

A few objects carry more than one of these keys. Contact verification errors, for example, set code and error_code to the same value and put the message in both error and message. Extra fields (such as channel_id, contact_id, required_balance) are documented on each endpoint's guide.

Missing scope​

A 403 caused by a missing scope keeps the message string in detail and adds machine-readable fields beside it:

{
"detail": "Missing required permission(s): automation:publish",
"code": "missing_scope",
"required_scopes": ["automation:publish"],
"require_all": false,
"reason": "token_scope"
}
FieldDescription
codeAlways missing_scope
required_scopesThe scopes the endpoint accepts. With require_all: false any one of them is enough; with true all are needed.
reasontoken_scope: the API key or OAuth access token does not carry the scope. Create a key with it, or have the user reconnect your app and consent to it. role: the signed-in user's role does not grant it. A workspace admin must change the role.

Branch on code and reason, not on the text in detail. A few permission errors carry no code. One example is a scope that OAuth apps can never be granted, which no consent can fix. Treat a 403 without code as not fixable by adding a scope.

3. Request validation errors (422)​

When the request body, query or path does not match the schema, the response is 422 with detail as a list, one entry per problem, plus the submitted body:

{
"detail": [
{
"type": "missing",
"loc": ["body", "text"],
"msg": "Field required",
"input": {"conversation_id": "conv_8f3a2b1c"}
}
],
"body": {"conversation_id": "conv_8f3a2b1c"}
}
  • loc is the path to the field (["body", "<field>"], ["query", "<param>"], ...).
  • msg is readable text, type is a stable category (missing, string_too_long, value_error, ...). Entries can also carry ctx and url.
  • Values of secret fields (tokens, passwords, keys) are redacted in input and body.

Some endpoints also return 422 for business rules, with an object in detail instead of a list (for example {"detail": {"error_code": "channel_option_not_supported", ...}}). Check whether detail is a list before you read it.

4. Bodies without a detail wrapper​

A few responses put their fields at the top level:

StatusBodyWhen
429{"detail": "Rate limit exceeded. Please slow down.", "error": "rate_limit_exceeded", "category": "api_token", "retry_after": 12}General request limit (category is api_token for API keys and OAuth tokens, ui_user for a signed-in person). See Rate Limits
429{"error": "Rate limit exceeded: 10 per 1 minute"}Per-endpoint limits (for example Knowledge Base crawling)
409{"error": "...", "code": "TAG_IN_USE_BY_INBOX", "details": {"inboxes": [...]}}Deleting a tag (or a custom field: CUSTOM_FIELD_IN_USE_BY_INBOX) that an inbox filter rule still uses
401{"detail": "HubSpot connection needs to be reconnected", "error": "hubspot_reconnect_required"}HubSpot endpoints after the HubSpot connection expired

5. Server errors​

500 responses may not be JSON at all (Internal Server Error as plain text). Don't assume a body on 5xx.

Common errors​

StatusTypical bodyCauseFix
401{"detail": "Invalid or expired API token"}Token unknown, revoked or expiredCheck the Authorization: Bearer s7_api_... header or create a new token
401{"detail": "OAuth access token expired. Please reconnect your account."}OAuth access token expiredRefresh the token. See Authentication
403{"detail": "Not authenticated"}No Authorization header sentAdd the header
403{"detail": "Missing required permission(s): campaigns:create", "code": "missing_scope", ...}The token or the signed-in user lacks a scope (see Missing scope)Grant one of required_scopes
403{"detail": {"code": "feature_disabled", "feature": "flows"}}The feature is not included in your planUpgrade the plan
403{"detail": {"message": "...", "block_reason": "blocked_by_admin"}}The workspace was blockedContact support
403{"detail": {"code": "request_failed", "message": "Something went wrong. Please contact [email protected]"}}Any endpoint can return this. The request cannot be processedDon't retry. Contact [email protected]
402{"detail": {"message": "...", "reason": "trial_expired"}}Account access is restricted for billing reasons. reason is one of account_suspended, account_cancelled, account_deactivated, trial_expired, trial_messages_exhausted, grace_period_expired, payment_overdue, payment_method_required. trial_messages_exhausted is returned only by requests that send a message (message sends, campaign send/schedule/retry, scheduled sends, templates, verifications); every other request keeps workingFix billing in Settings > Billing, or earn more trial messages
404{"detail": "Contact not found"}The ID doesn't exist or belongs to another workspaceCheck the ID and that the token belongs to the right workspace
409{"detail": {"error": "CONTACT_BLOCKED", "message": "...", "contact_id": "..."}}Sending to a blocked contactUnblock the contact first
422{"detail": [ ... ], "body": { ... }}Request doesn't match the schemaFix the fields listed in detail
429see Rate LimitsToo many requestsWait for Retry-After, then retry
503{"detail": "Authentication temporarily unavailable, please retry"}Temporary outage of a dependencyRetry with backoff
503{"detail": {"message": "...", "reason": "billing_check_unavailable"}}A send could not be checked against your account statusRetry after Retry-After seconds

Implementing error handling​

The helpers below read the code from whichever key an endpoint uses, and retry only on 429 and 5xx.

Python​

import time
import requests

API = "https://api.sendseven.com/api/v1"
HEADERS = {
"Authorization": "Bearer s7_api_your_token",
"Content-Type": "application/json",
}


class SendSevenError(Exception):
def __init__(self, status, code, message, body):
super().__init__(f"{status} {code or ''} {message}".strip())
self.status, self.code, self.message, self.body = status, code, message, body


def parse_error(response):
try:
body = response.json()
except ValueError:
return None, response.text, None
if not isinstance(body, dict):
return None, str(body), body
detail = body.get("detail")
if isinstance(detail, list): # 422 schema validation
message = "; ".join(
f"{'.'.join(str(p) for p in e.get('loc', []))}: {e.get('msg')}" for e in detail
)
return "validation_error", message, body
if isinstance(detail, dict):
code = detail.get("error_code") or detail.get("code") or detail.get("error")
return code, detail.get("message") or detail.get("error") or str(detail), body
# detail is a string, or the body has no detail wrapper
code = body.get("code") or body.get("error")
return code, detail or body.get("error") or "Unknown error", body


def api_request(method, path, max_retries=5, **kwargs):
for attempt in range(max_retries):
response = requests.request(method, f"{API}{path}", headers=HEADERS, **kwargs)

if response.status_code == 429 or response.status_code >= 500:
wait = int(response.headers.get("Retry-After", 2 ** attempt))
time.sleep(wait)
continue

if response.ok:
return response.json() if response.content else None

code, message, body = parse_error(response)
raise SendSevenError(response.status_code, code, message, body)

raise RuntimeError(f"Failed after {max_retries} retries")

JavaScript​

const API = 'https://api.sendseven.com/api/v1';

function parseError(body) {
if (!body || typeof body !== 'object') return { code: null, message: String(body) };
const { detail } = body;
if (Array.isArray(detail)) {
// 422 schema validation
return {
code: 'validation_error',
message: detail.map(e => `${(e.loc || []).join('.')}: ${e.msg}`).join('; '),
};
}
if (detail && typeof detail === 'object') {
return {
code: detail.error_code || detail.code || detail.error || null,
message: detail.message || detail.error || JSON.stringify(detail),
};
}
return { code: body.code || body.error || null, message: detail || body.error || 'Unknown error' };
}

async function apiRequest(method, path, body = null, attempt = 0) {
const response = await fetch(`${API}${path}`, {
method,
headers: {
'Authorization': 'Bearer s7_api_your_token',
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});

if ((response.status === 429 || response.status >= 500) && attempt < 5) {
const wait = Number(response.headers.get('Retry-After')) || 2 ** attempt;
await new Promise(r => setTimeout(r, wait * 1000));
return apiRequest(method, path, body, attempt + 1);
}

if (response.ok) return response.status === 204 ? null : response.json();

let payload;
try { payload = await response.json(); } catch { payload = await response.text(); }
const { code, message } = parseError(payload);

if (response.status === 401) throw new Error(`Authentication failed: ${message}`);
if (response.status === 403) throw new Error(`Forbidden: ${message}`);
if (code === 'CONTACT_BLOCKED') throw new Error('The contact is blocked.');
throw new Error(`${response.status} ${code ?? ''} ${message}`.trim());
}

Retry guidelines​

StatusRetry?Reason
400NoFix the request
401NoFix authentication (refresh an OAuth token first)
402NoResolve the billing issue
403NoFix permissions or plan
404NoResource doesn't exist
409NoResolve the conflict (some endpoints document a retry, for example after confirming a warning)
422NoFix validation errors
429YesWait for Retry-After when present
500YesTransient server error
503YesService temporarily unavailable; honour Retry-After when present
tip

Only retry on 429 and 5xx errors. Client errors (4xx) indicate a problem with the request itself. Because a 5xx on a send can happen after the message was accepted, check the conversation before you resend.

Message delivery failures are not HTTP errors​

A POST /api/v1/messages request that is accepted returns 201 with the message in status queued. If the send fails afterwards, the message moves to failed and the message.failed webhook fires once, after the last attempt. That covers failures SendSeven records while sending (for example insufficient_sms_balance when the prepaid SMS balance is empty, a disconnected channel, or a provider rejection) and failures the channel reports later (for example a WhatsApp error code such as 131047). The reason is on the message as error_code and error_message (GET /api/v1/messages/{message_id}). In the webhook, the text is in data.error and the structured errors are in data.message.meta.last_failed_error.errors[]. Listen for that webhook rather than relying on the HTTP status of the send. The rich send endpoints (/messages/send/interactive, /send/contacts, /send/sticker, /send/carousel) send right away: a failed send is reported in their response and also fires message.failed once. Email sends that fail in the email pipeline are the exception and do not fire it. Campaign sends fire campaign.message.failed.