# Errors & Rate Limits (https://docs2.smao.ai/docs/errors)



<Callout type="warn" title="The two REST APIs use different error envelopes">
  The **Public API** wraps errors in `{"error": {"code", "message"}}` with lowercase `snake_case` codes.
  The **Distributor API** returns a flat `{"status": "error", "errorCode", "message"}` object with `SCREAMING_SNAKE_CASE` codes.
  Parsers written for one will silently fail on the other.
</Callout>

## Public API errors [#public-api-errors]

Every non-2xx response from `/api/v1` has this shape:

```json
{
  "error": {
    "code": "not_found",
    "message": "Not found"
  }
}
```

| Code                      | HTTP | Meaning                                                                                                                                                                                      | Retry?                                                    |
| ------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| `validation_failed`       | 400  | Request body or query parameters failed validation                                                                                                                                           | No — fix the request. Check `details`                     |
| `unauthorized`            | 401  | Missing or invalid API key                                                                                                                                                                   | No — fix the key                                          |
| `subscription_required`   | 402  | A feature required by this endpoint is not enabled for the organization                                                                                                                      | No — upgrade the plan                                     |
| `forbidden`               | 403  | The API key's role lacks permission for this action                                                                                                                                          | No — use a key with a broader role                        |
| `not_found`               | 404  | Resource does not exist (or belongs to another organization)                                                                                                                                 | No — check the ID                                         |
| `conflict`                | 409  | Resource is in use or the operation would violate an invariant                                                                                                                               | No — resolve the conflict first                           |
| `outbound_target_blocked` | 409  | The exact outbound target is blocked for the selected assistant. Manage lists and assignments in Dashboard → Settings → Blocked callers; configuration is not exposed through the Public API | No — choose another target or change the Dashboard policy |
| `rate_limit_exceeded`     | 429  | Rate limit exceeded, an outbound assistant is in cooldown, or the limiter is unavailable                                                                                                     | Yes — use `Retry-After` when present; otherwise back off  |
| `internal_error`          | 500  | Unexpected server error; the operation did not complete                                                                                                                                      | Reads: yes. Mutations: check resource state first         |

### Validation details [#validation-details]

`validation_failed` responses carry an optional `details` array. Each entry has a
`path` (array of field names and array indices) and a `message`:

```json
{
  "error": {
    "code": "validation_failed",
    "message": "Validation failed",
    "details": [
      { "path": ["first_name"], "message": "\"first_name\" is required" },
      { "path": ["phone_numbers", 0], "message": "\"phone_numbers[0]\" must be a string" }
    ]
  }
}
```

Per-endpoint error responses are listed in the [Public API reference](/docs/public).

## MCP errors [#mcp-errors]

Authentication and entitlement failures happen before MCP dispatch and are
normal HTTP errors. `401` means the credential is missing or invalid; OAuth
clients use its `WWW-Authenticate` metadata to start authorization. `402
subscription_required` means the organization is not entitled — reconnecting
OAuth cannot fix it. `403` means the user or organization lifecycle blocks the
request, `429` means retry after backoff, and `503` means OAuth configuration or
the current organization authorization context cannot be loaded.

Once dispatch starts, tool validation and service errors are MCP tool results
with `isError: true` and the Public API `{ "error": { "code", "message",
"details?" } }` body. Branch on `code`, not localized `message`. See
[MCP setup and access rules](/docs/mcp).

## Distributor API errors [#distributor-api-errors]

Every non-2xx response from `/api/distributor/v1` has this shape:

```json
{
  "status": "error",
  "errorCode": "RATE_LIMITED",
  "message": "Too many requests for this API key."
}
```

Match on `errorCode`, not on `message` — messages can change. Common
integration-relevant codes:

| errorCode                                                  | HTTP | Meaning / action                                                                                     |
| ---------------------------------------------------------- | ---- | ---------------------------------------------------------------------------------------------------- |
| `MISSING_API_KEY`, `INVALID_API_KEY`                       | 401  | No/bad bearer key. Fix the key                                                                       |
| `API_KEY_EXPIRED`                                          | 401  | Key past its expiry (default 365 days). Request a replacement from SMAO                              |
| `API_VERSION_NOT_ALLOWED`                                  | 403  | The key metadata does not authorize Distributor API v1. Request a valid v1 key                       |
| `MISSING_PERMISSION`                                       | 403  | Key lacks the required permission. Permission sets are immutable — you need a different key          |
| `VALIDATION_ERROR`                                         | 400  | Bad request body or parameters. Fix and resend                                                       |
| `INVALID_ID`, `INVALID_DATE_RANGE`, `DATE_RANGE_TOO_LARGE` | 400  | An identifier or date window is invalid. Fix and resend                                              |
| `NOT_FOUND`                                                | 404  | Resource not found in this distributor's scope                                                       |
| `RATE_LIMITED`                                             | 429  | Rate limit exceeded. Wait `Retry-After` seconds, then retry                                          |
| `RATE_LIMIT_UNAVAILABLE`                                   | 503  | Rate-limit store unavailable (fail-closed). Retry with backoff                                       |
| `INTERNAL_ERROR`                                           | 500  | Unexpected server error. There is no idempotency-key header — check state before retrying a mutation |

### Partner and organization codes [#partner-and-organization-codes]

| errorCode                        | HTTP    | Meaning / action                                                                                                     |
| -------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------- |
| `DUPLICATE_EXTERNAL_PARTNER_ID`  | 409     | `externalPartnerId` already exists. Fetch and reconcile the existing partner                                         |
| `DUPLICATE_EXTERNAL_CUSTOMER_ID` | 409     | `externalCustomerId` already exists. Fetch and reconcile the existing customer                                       |
| `PARTNER_NOT_FOUND`              | 404     | The requested active v1 partner was not found in this distributor's scope                                            |
| `PARTNER_HAS_ORGANIZATIONS`      | 409     | The partner still has linked organizations, including its internal MSP organization; v1 does not cascade-delete them |
| `INVALID_PLAN`, `PLAN_NOT_FOUND` | 400/404 | The requested assignable plan is invalid, or no subscription exists for billing                                      |
| `ORG_CREATION_FAILED`            | 500     | The partner's internal organization could not be provisioned                                                         |

The full enums and per-endpoint responses are in the
[Distributor v1 reference](/docs/distributor-v1).

## Rate limits [#rate-limits]

The REST APIs limit per API key. For the Public API, matched operations return the
three headers after authentication, the feature gate, and the limiter succeed.
Authentication failures, the `402` feature gate, unmatched routes, and a
limiter-storage failure do not carry the complete header set:

```bash
curl -sD - -o /dev/null "https://api2.smao.ai/api/v1/assistants" \
  -H "Authorization: Bearer $SMAO_API_KEY"
```

```
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 997
X-RateLimit-Reset: 1776902940
```

`X-RateLimit-Reset` is a Unix timestamp (seconds) of the window reset.

| API         | Bucket                                                                              | Limit                                                                  |
| ----------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| Public      | `read` — authenticated documented GET operations                                    | 1,000 / min default                                                    |
| Public      | `write` — authenticated documented POST / PATCH / DELETE operations except `/calls` | 200 / min default                                                      |
| Public      | `outbound_trigger` — POST `/calls`                                                  | 60 / hour default, plus one outbound call per assistant per 60 seconds |
| MCP         | read tools, per organization and OAuth user or API key                              | 1,000 / min                                                            |
| MCP         | write tools, per organization and OAuth user or API key                             | 200 / min                                                              |
| Distributor | all authenticated endpoints (`/health` is unlimited)                                | 60 / min (default; per-key configurable)                               |

Distributor responses that pass authentication include `Retry-After` (seconds)
for the current rate-limit window, even before the bucket is exhausted. On
`429`, sleep that long before retrying; `X-RateLimit-Reset` carries the same
information as an absolute Unix timestamp. A Public API `429` caused by the per-assistant
outbound cooldown or an unavailable limiter store does not include
`Retry-After`; use exponential backoff, with at least 60 seconds for the
assistant cooldown.

All three surfaces fail closed. If the rate-limit store cannot be reached, the
Public API rejects with 429 `rate_limit_exceeded` without rate-limit headers,
the Distributor API rejects with 503 `RATE_LIMIT_UNAVAILABLE`, and MCP rejects
with a 429 JSON-RPC error plus `Retry-After`. Retry with exponential backoff; do
not treat it as a hard failure.
