Errors & Rate Limits
Error envelopes and codes for both APIs, rate-limit buckets, and retry behavior.
The two 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.
Public API errors
Every non-2xx response from /api/v1 has this shape:
{
"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 |
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_failed responses carry an optional details array. Each entry has a
path (array of field names and array indices) and a message:
{
"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.
Distributor API errors
Every non-2xx response from /api/distributor/v2 (and the supported compatibility
contract at /api/distributor/v1) has this shape:
{
"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 is not authorized for the requested Distributor API version. Use a version-matched 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 |
v1 compatibility 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 |
v2 organization and license codes
| errorCode | HTTP | Meaning / action |
|---|---|---|
DUPLICATE_EXTERNAL_ORGANIZATION_ID | 409 | externalOrganizationId already exists. Fetch and reconcile the existing organization |
PARENT_ORGANIZATION_NOT_FOUND | 404 | The requested parent MSP was not found in this distributor's scope |
ORGANIZATION_HAS_CHILDREN | 409 | An MSP still has customer organizations. Delete the customers first |
ORGANIZATION_CONFLICT | 409 | The organization or its MSP hierarchy is being changed concurrently. Re-read its state and retry |
LICENSE_NOT_AVAILABLE | 409 | The license is not available for this assignment. Reconcile its current state |
LICENSE_CONFLICT | 409 | The license changed concurrently. Fetch current state before retrying |
LICENSE_OWNERSHIP_CONFLICT | 409 | The external license belongs to a different MSP in this distributor's scope |
INVALID_PLAN, PLAN_NOT_FOUND | 400/404 | The requested plan is not assignable, or no subscription exists for billing |
ORG_CREATION_FAILED, ORG_CLEANUP_FAILED, INVITATION_CREATION_FAILED, SUBSCRIPTION_CREATION_FAILED | 500 | Provisioning did not complete cleanly. Reconcile organization, license, subscription, and invitation state before retrying |
The full enums and per-endpoint responses are in the v1 reference and v2 reference.
Rate limits
Both 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:
curl -sD - -o /dev/null "https://api.smao.ai/api/v1/assistants" \
-H "Authorization: Bearer $SMAO_API_KEY"X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 997
X-RateLimit-Reset: 1776902940X-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 |
| 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.
Both APIs 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 — retry with exponential backoff, do not treat it as a
hard failure.