SMAO API Documentation

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"
  }
}
CodeHTTPMeaningRetry?
validation_failed400Request body or query parameters failed validationNo — fix the request. Check details
unauthorized401Missing or invalid API keyNo — fix the key
subscription_required402A feature required by this endpoint is not enabled for the organizationNo — upgrade the plan
forbidden403The API key's role lacks permission for this actionNo — use a key with a broader role
not_found404Resource does not exist (or belongs to another organization)No — check the ID
conflict409Resource is in use or the operation would violate an invariantNo — resolve the conflict first
rate_limit_exceeded429Rate limit exceeded, an outbound assistant is in cooldown, or the limiter is unavailableYes — use Retry-After when present; otherwise back off
internal_error500Unexpected server error; the operation did not completeReads: 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:

errorCodeHTTPMeaning / action
MISSING_API_KEY, INVALID_API_KEY401No/bad bearer key. Fix the key
API_KEY_EXPIRED401Key past its expiry (default 365 days). Request a replacement from SMAO
API_VERSION_NOT_ALLOWED403The key is not authorized for the requested Distributor API version. Use a version-matched key
MISSING_PERMISSION403Key lacks the required permission. Permission sets are immutable — you need a different key
VALIDATION_ERROR400Bad request body or parameters. Fix and resend
INVALID_ID, INVALID_DATE_RANGE, DATE_RANGE_TOO_LARGE400An identifier or date window is invalid. Fix and resend
NOT_FOUND404Resource not found in this distributor's scope
RATE_LIMITED429Rate limit exceeded. Wait Retry-After seconds, then retry
RATE_LIMIT_UNAVAILABLE503Rate-limit store unavailable (fail-closed). Retry with backoff
INTERNAL_ERROR500Unexpected server error. There is no idempotency-key header — check state before retrying a mutation

v1 compatibility codes

errorCodeHTTPMeaning / action
DUPLICATE_EXTERNAL_PARTNER_ID409externalPartnerId already exists. Fetch and reconcile the existing partner
DUPLICATE_EXTERNAL_CUSTOMER_ID409externalCustomerId already exists. Fetch and reconcile the existing customer
PARTNER_NOT_FOUND404The requested active v1 partner was not found in this distributor's scope
PARTNER_HAS_ORGANIZATIONS409The partner still has linked organizations, including its internal MSP organization; v1 does not cascade-delete them
INVALID_PLAN, PLAN_NOT_FOUND400/404The requested assignable plan is invalid, or no subscription exists for billing
ORG_CREATION_FAILED500The partner's internal organization could not be provisioned

v2 organization and license codes

errorCodeHTTPMeaning / action
DUPLICATE_EXTERNAL_ORGANIZATION_ID409externalOrganizationId already exists. Fetch and reconcile the existing organization
PARENT_ORGANIZATION_NOT_FOUND404The requested parent MSP was not found in this distributor's scope
ORGANIZATION_HAS_CHILDREN409An MSP still has customer organizations. Delete the customers first
ORGANIZATION_CONFLICT409The organization or its MSP hierarchy is being changed concurrently. Re-read its state and retry
LICENSE_NOT_AVAILABLE409The license is not available for this assignment. Reconcile its current state
LICENSE_CONFLICT409The license changed concurrently. Fetch current state before retrying
LICENSE_OWNERSHIP_CONFLICT409The external license belongs to a different MSP in this distributor's scope
INVALID_PLAN, PLAN_NOT_FOUND400/404The requested plan is not assignable, or no subscription exists for billing
ORG_CREATION_FAILED, ORG_CLEANUP_FAILED, INVITATION_CREATION_FAILED, SUBSCRIPTION_CREATION_FAILED500Provisioning 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: 1776902940

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

APIBucketLimit
Publicread — authenticated documented GET operations1,000 / min default
Publicwrite — authenticated documented POST / PATCH / DELETE operations except /calls200 / min default
Publicoutbound_trigger — POST /calls60 / hour default, plus one outbound call per assistant per 60 seconds
Distributorall 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.

On this page