Reliability

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`.

validation_error
json
{
  "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.

Exact error codes

Use the machine-readable `error` field for control flow. Use `message` for logs and operator-facing guidance.

Error examples

These examples show the response shapes most integrations should handle explicitly.

common-errors.json
json
[
  {
    "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.

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.

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.

node-backoff.ts
ts
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.

python-backoff.py
py
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.

Next steps