---
title: Errors
path: concepts/errors
status: published
---

# 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:

### SendGrid-style (for `/v3/mail/send` and related)

```json
{
  "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
{
  "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
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
{"detail": "Missing Authorization header"}
```

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

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

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

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

Refresh with `/v3/auth/refresh`.

### Authorization

```json
{"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
{
  "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
{
  "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.1 429 Too Many Requests
Retry-After: 5

{"detail": "Rate limit exceeded"}
```

Wait `Retry-After` seconds before retrying. See [Rate Limiting](rate-limiting) for the limits per endpoint.

### Payload too large

```json
{"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
{"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
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
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

- [Error Codes](../reference/error-codes) — the exhaustive list of errors by endpoint.
- [Rate Limiting](rate-limiting) — how `429` is classified and how to stay under it.
- [Your First Integration](../tutorials/first-integration) — a complete send loop with error handling.
