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:
| Key | Example | Used 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"
}
| Field | Description |
|---|---|
code | Always missing_scope |
required_scopes | The scopes the endpoint accepts. With require_all: false any one of them is enough; with true all are needed. |
reason | token_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"}
}
locis the path to the field (["body", "<field>"],["query", "<param>"], ...).msgis readable text,typeis a stable category (missing,string_too_long,value_error, ...). Entries can also carryctxandurl.- Values of secret fields (tokens, passwords, keys) are redacted in
inputandbody.
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:
| Status | Body | When |
|---|---|---|
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
| Status | Typical body | Cause | Fix |
|---|---|---|---|
401 | {"detail": "Invalid or expired API token"} | Token unknown, revoked or expired | Check the Authorization: Bearer s7_api_... header or create a new token |
401 | {"detail": "OAuth access token expired. Please reconnect your account."} | OAuth access token expired | Refresh the token. See Authentication |
403 | {"detail": "Not authenticated"} | No Authorization header sent | Add 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 plan | Upgrade the plan |
403 | {"detail": {"message": "...", "block_reason": "blocked_by_admin"}} | The workspace was blocked | Contact support |
403 | {"detail": {"code": "request_failed", "message": "Something went wrong. Please contact [email protected]"}} | Any endpoint can return this. The request cannot be processed | Don'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 working | Fix billing in Settings > Billing, or earn more trial messages |
404 | {"detail": "Contact not found"} | The ID doesn't exist or belongs to another workspace | Check the ID and that the token belongs to the right workspace |
409 | {"detail": {"error": "CONTACT_BLOCKED", "message": "...", "contact_id": "..."}} | Sending to a blocked contact | Unblock the contact first |
422 | {"detail": [ ... ], "body": { ... }} | Request doesn't match the schema | Fix the fields listed in detail |
429 | see Rate Limits | Too many requests | Wait for Retry-After, then retry |
503 | {"detail": "Authentication temporarily unavailable, please retry"} | Temporary outage of a dependency | Retry with backoff |
503 | {"detail": {"message": "...", "reason": "billing_check_unavailable"}} | A send could not be checked against your account status | Retry 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
| Status | Retry? | Reason |
|---|---|---|
| 400 | No | Fix the request |
| 401 | No | Fix authentication (refresh an OAuth token first) |
| 402 | No | Resolve the billing issue |
| 403 | No | Fix permissions or plan |
| 404 | No | Resource doesn't exist |
| 409 | No | Resolve the conflict (some endpoints document a retry, for example after confirming a warning) |
| 422 | No | Fix validation errors |
| 429 | Yes | Wait for Retry-After when present |
| 500 | Yes | Transient server error |
| 503 | Yes | Service temporarily unavailable; honour Retry-After when present |
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.