Errors and limits
Handle validation errors, sender problems, quota limits, and rate limits cleanly.
How failures are returned
Synchronous API errors mean the request was not accepted. Message status failures mean the request was accepted but delivery later failed.
Your integration should handle both. Store `message_id`, poll status when needed, and surface clear recovery instructions for operators.
Validation errors from Zod return HTTP 422 with `error`, `message`, and `details`. Application errors return `error`, `message`, and optional `details`.
{
"error": "validation_error",
"message": "Request validation failed",
"details": {
"fieldErrors": {
"message": ["message or attachment is required"]
},
"formErrors": []
}
}Common status codes
The backend uses standard HTTP status codes for authentication, validation, sender assignment, and rate limiting failures.
- 400 or 422: request body or query parameters are invalid.
- 401: missing or invalid API key/session.
- 403: API client is not allowed to use the sender or action.
- 404: resource does not exist in the accessible workspace.
- 409: conflicting state, duplicate action, or business-rule conflict.
- 429: rate limit exceeded.
- 500/502: backend or dependency failure.
Exact error codes
Use the machine-readable `error` field for control flow. Use `message` for logs and operator-facing guidance.
- unauthorized: missing or invalid `x-api-key`; check the header and copied key.
- forbidden: wrong API key role, sender not assigned, or message not accessible to this API client.
- sender_not_connected: sender exists but is not connected; scan QR again.
- sender_disabled: sender is paused; re-enable or reconnect it.
- receiver_invalid: phone number is missing country code or has invalid length.
- receiver_blocked: recipient is blocked, unsubscribed, or archived in contacts.
- message_empty: request has no text and no attachment.
- daily_limit_reached: sender daily message limit is exhausted.
- monthly_quota_exceeded or plan_limit_reached: workspace plan limit is exhausted.
- attachments_not_enabled: the workspace plan does not include image or document attachments; upgrade the plan or send a link instead.
- attachment_type_not_supported: the file is not an allowed image or document MIME type; use a supported file type.
- attachment_size_exceeded: decoded file size exceeds the plan limit; compress the file or upgrade the plan.
- attachment_monthly_limit_reached: the rolling 30-day attachment allowance is exhausted; wait for usage to roll over or upgrade the plan.
- storage_quota_exceeded: active media storage is full; wait for retained files to expire, send a link, or upgrade the plan.
- duplicate_idempotency_key: a database uniqueness conflict occurred for a reused idempotency key.
- rate_limited: HTTP rate limit or OTP receiver limit was exceeded.
- not_found: sender, message, webhook, or route does not exist in the accessible workspace.
Error examples
These examples show the response shapes most integrations should handle explicitly.
[
{
"status": 401,
"body": {
"error": "unauthorized",
"message": "Invalid x-api-key"
}
},
{
"status": 403,
"body": {
"error": "forbidden",
"message": "API client is not assigned to this sender"
}
},
{
"status": 409,
"body": {
"error": "sender_not_connected",
"message": "Sender is not connected (status: logged_out). Please scan QR again."
}
},
{
"status": 429,
"body": {
"error": "rate_limited",
"message": "Too many requests. Slow down."
}
},
{
"status": 429,
"body": {
"error": "daily_limit_reached",
"message": "Sender reached its daily limit."
}
},
{
"status": 429,
"body": {
"error": "monthly_quota_exceeded",
"message": "Workspace monthly message quota has been reached."
}
},
{
"status": 422,
"body": {
"error": "receiver_blocked",
"message": "Number 212612345678 is blocked."
}
},
{
"status": 404,
"body": {
"error": "not_found",
"message": "Message 2b7a0bd5-1a0d-4e6c-85b1-a7f44f92dfb0 not found"
}
},
{
"status": 409,
"body": {
"error": "duplicate_idempotency_key",
"message": "A message with this idempotency key already exists."
}
}
]Rate and quota controls
The platform enforces general API rate limits, client API rate limits, OTP-specific rate limits, receiver-level OTP limits, sender daily limits, and monthly plan limits.
Do not retry 429 responses immediately. Use backoff and show a clear operator message.
- API_RATE_LIMIT_PER_MINUTE
- CLIENT_API_RATE_LIMIT_PER_MINUTE
- OTP_HTTP_RATE_LIMIT_PER_MINUTE
- OTP_RECEIVER_LIMIT_PER_HOUR
- Plan-based monthly message limits
Rate limit headers
HTTP rate-limited responses include standard rate-limit headers from the Redis-backed limiter. Use them to delay retries instead of guessing.
`Retry-After` is present when the request exceeds the current window.
- RateLimit-Limit: maximum requests allowed in the current window.
- RateLimit-Remaining: requests left in the current window.
- RateLimit-Reset: seconds until the current window resets.
- Retry-After: seconds to wait after a 429 response.
- OTP receiver limit: can also return `rate_limited` when too many OTPs target the same receiver.
Retry guidance
Retry only failures that can succeed without changing the request. Never retry invalid input, wrong credentials, wrong sender assignment, blocked receivers, or exhausted quota in a tight loop.
- Retry with backoff: network timeout, 500, 502, 503, and 429 after `Retry-After`.
- Do not retry until fixed: 401, 403, 404, 409 sender state, 422 validation, blocked recipient, daily limit, and plan quota.
- Use idempotency: keep the same `idempotency_key` when retrying after a timeout so duplicate sends collapse into one message.
async function sendWithBackoff(request: () => Promise<Response>) {
for (let attempt = 0; attempt < 3; attempt += 1) {
const response = await request();
if (response.ok) return response.json();
const retryAfter = Number(response.headers.get("Retry-After") ?? "0");
if (![429, 500, 502, 503].includes(response.status)) {
throw new Error(await response.text());
}
const delayMs = retryAfter > 0 ? retryAfter * 1000 : 500 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw new Error("HookMessage request failed after retries");
}Python backoff
Use the same retry rule in Python: respect `Retry-After`, retry only temporary failures, and keep the same idempotency key.
import time
def send_with_backoff(call):
for attempt in range(3):
response = call()
if response.status_code < 400:
return response.json()
if response.status_code not in (429, 500, 502, 503):
raise RuntimeError(response.text)
retry_after = int(response.headers.get("Retry-After") or 0)
delay = retry_after if retry_after > 0 else 0.5 * (2 ** attempt)
time.sleep(delay)
raise RuntimeError("HookMessage request failed after retries")Need help?
Use Book Integration Help if you want setup help for OTP or automated customer messaging.
Continue building
Next steps
Start here
A plain-language map of Free Sandbox, paid sender modes, credentials, and the first successful request.
Free Sandbox quickstart
The shortest beginner path from a verified account to a queued Free Sandbox OTP.
Glossary
Understand workspaces, keys, senders, receivers, queue states, webhooks, and idempotency.
Authentication and access
HookMessage separates customer API keys, dashboard sessions, and internal platform access.