Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

Errors

ScaiSend returns structured error responses. The HTTP status code classifies the error broadly; the response body gives you the specifics. This page describes the shapes you'll see, when each is used, and how to handle them.

The two shapes#

ScaiSend uses two different error shapes depending on the endpoint:

json
1
2
3
4
5
6
7
8
9
{
  "errors": [
    {
      "message": "The template_id must start with 'd-'",
      "field": "template_id",
      "help": "https://scaisend.scailabs.ai/docs/06-reference/02-mail-send#template-id"
    }
  ]
}
  • errors is an array. Multiple errors may be returned for a single request (e.g., several validation failures at once).
  • message is human-readable; safe to display or log.
  • field is present when the error relates to a specific input field. Use it to highlight the offending field in a UI.
  • help is optional; when present, it's a link to relevant documentation.

FastAPI-style (for everything else)#

json
1
2
3
{
  "detail": "Template not found: d-abc123"
}
  • detail is the human-readable description. Single string for most errors.
  • For Pydantic validation errors, detail becomes an array of field-level error objects: {"loc": ["body", "personalizations"], "msg": "...", "type": "..."}.

Which shape where#

Endpoint family Shape
/v3/mail/send, /v3/mail/batch, and related sending SendGrid-style (errors[])
/v3/api_keys, /v3/templates, /v3/messages, /v3/stats, /v3/suppression/*, /v3/asm/*, /v3/user/webhooks, /v3/images FastAPI-style (detail)
/api/admin/* FastAPI-style (detail)
/v3/auth/* FastAPI-style (detail)

Your client code should handle both. A typical pattern:

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def extract_error(resp) -> str:
    try:
        body = resp.json()
    except ValueError:
        return resp.text
    if "errors" in body:
        return "; ".join(e.get("message", "") for e in body["errors"])
    if "detail" in body:
        return body["detail"] if isinstance(body["detail"], str) else str(body["detail"])
    return resp.text

HTTP status codes#

Status Meaning When
200 OK Success Read endpoints, updates
201 Created Resource created POST that creates a new entity (API key, template, domain, webhook)
202 Accepted Queued for processing POST /v3/mail/send — always 202 on success
204 No Content Success, no body Successful DELETE
400 Bad Request Malformed request Missing fields, wrong types, logic violations (e.g., both content and template_id)
401 Unauthorized Missing/invalid credentials No Authorization header; expired JWT; invalid API key
403 Forbidden Insufficient permissions Missing scope; suspended tenant; cross-tenant access
404 Not Found Resource not found Unknown message ID; unknown template ID; endpoint path typo
409 Conflict State conflict Domain already exists; duplicate role name
413 Payload Too Large Request body exceeds limit /v3/mail/send body over 20 MB
422 Unprocessable Entity Schema validation failed Pydantic caught a type or format error before business logic
429 Too Many Requests Rate limited Too many requests from this API key or tenant
500 Internal Server Error Unexpected server error Unhandled exception; file a support ticket with the request_id
503 Service Unavailable Dependency down MySQL or Redis unreachable; ScaiKey JWKS endpoint down

Retry classification#

Not every error is retryable. Retrying a 4xx almost never helps (your request is malformed); retrying a 5xx or 429 often does.

Code Retry? How
400 / 422 No Fix the request
401 No Refresh JWT or rotate API key
403 No Get the right scope; don't retry
404 No The resource doesn't exist
409 No Resolve the state conflict
413 No Shrink the payload (typically attachments)
429 Yes Honor Retry-After header; exponential backoff if absent
500 Yes, limited Max 2–3 attempts, exponential backoff
503 Yes Exponential backoff; check service health if persistent

A safe default: retry 429, 500, and 503, up to 4 attempts total, with exponential backoff + jitter. Do not retry anything else.

Common error scenarios#

Authentication#

json
1
{"detail": "Missing Authorization header"}

Add Authorization: Bearer <your-api-key-or-jwt>.

json
1
{"detail": "Invalid API key"}

Typo, revoked, or deleted key. Check GET /v3/api_keys.

json
1
{"detail": "JWT expired"}

Refresh with /v3/auth/refresh.

Authorization#

json
1
{"detail": "Missing required scope: mail.send"}

The credential doesn't have the needed scope. Either create a differently scoped key, or assign the missing permission to the user's role.

Validation#

/v3/mail/send:

json
1
2
3
4
5
6
{
  "errors": [
    {"message": "Field required", "field": "personalizations"},
    {"message": "Input should be a valid email", "field": "from.email"}
  ]
}

A request body with multiple problems returns all of them at once. Fix each.

json
1
2
3
4
5
{
  "errors": [
    {"message": "template_id must start with 'd-'", "field": "template_id"}
  ]
}

Dynamic template IDs start with d-. Legacy template IDs aren't valid on /v3/mail/send.

Rate limiting#

http
1
2
3
4
HTTP/1.1 429 Too Many Requests
Retry-After: 5

{"detail": "Rate limit exceeded"}

Wait Retry-After seconds before retrying. See Rate Limiting for the limits per endpoint.

Payload too large#

json
1
{"detail": "Request body exceeds maximum size of 20 MB"}

The total serialized JSON (including base64-encoded attachments) must fit under 20 MB. Large attachments eat into that quickly — base64 adds ~33%. For very large files, host them externally and link from the email body.

Not found#

json
1
{"detail": "Template not found: d-abc123"}

The template doesn't exist, or isn't visible to your tenant. Check that the template belongs to your tenant and that the ID is spelled correctly.

Error handling pattern#

A pragmatic retry loop that surfaces a useful error when retry is exhausted:

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import os, time, random, httpx

RETRYABLE = {429, 500, 503}


def request_with_retry(method: str, path: str, *, max_attempts: int = 4, **kwargs):
    url = f"https://scaisend.scailabs.ai{path}"
    headers = {
        "Authorization": f"Bearer {os.environ['SCAISEND_API_KEY']}",
        **kwargs.pop("headers", {}),
    }
    for attempt in range(max_attempts):
        resp = httpx.request(method, url, headers=headers, **kwargs)
        if resp.is_success:
            return resp
        if resp.status_code not in RETRYABLE or attempt == max_attempts - 1:
            resp.raise_for_status()
        retry_after = int(resp.headers.get("Retry-After", "0"))
        time.sleep(retry_after or (2 ** attempt) + random.random())
    raise RuntimeError("unreachable")
typescript
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const RETRYABLE = new Set([429, 500, 503]);

async function request<T>(
  method: string,
  path: string,
  body?: unknown,
  maxAttempts = 4,
): Promise<T> {
  const url = `https://scaisend.scailabs.ai${path}`;
  const headers: Record<string, string> = {
    "Authorization": `Bearer ${process.env.SCAISEND_API_KEY}`,
    "Content-Type": "application/json",
  };
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    const resp = await fetch(url, {
      method,
      headers,
      body: body ? JSON.stringify(body) : undefined,
    });
    if (resp.ok) return resp.json() as Promise<T>;
    if (!RETRYABLE.has(resp.status) || attempt === maxAttempts - 1) {
      const detail = await resp.text();
      throw new Error(`ScaiSend ${resp.status}: ${detail}`);
    }
    const retryAfter = Number(resp.headers.get("Retry-After") ?? 0);
    const delay = (retryAfter || Math.pow(2, attempt) + Math.random()) * 1000;
    await new Promise((r) => setTimeout(r, delay));
  }
  throw new Error("unreachable");
}

Wrap it once; use it everywhere.

What's next#

Updated 2026-05-17 01:33:26 View source (.md) rev 1