Errors, rate limits, versioning
How the API behaves at the edges. Error response shape, the status-code table, per-route rate-limit caps with their retry semantics, and the v1 versioning contract that lets us iterate without breaking your code.
Error shape
Every error response is a single JSON object with a consistent shape. Parse it once and you have a generic error path for the entire API.
{
"statusCode": 400,
"error": "invalid_body",
"message": "Human-readable description",
"detail": {
"fieldErrors": {
"email": "Required",
"subject": "Must be 1-200 characters"
}
}
}Fields:
statusCodemirrors the HTTP status. Always present.erroris a short stable machine-readable slug. Always present. Use this for branching in code (do not parsemessage).messageis a human-readable description. Safe to display to logged-in agents; never log raw messages to public surfaces.detailis optional. Validation failures include afieldErrorsmap keyed by request-body path.
Status codes
| Status | Slug | When it fires |
|---|---|---|
| 400 | invalid_body / invalid_query | Request payload failed validation. detail.fieldErrors shows what. |
| 401 | unauthorized / token_revoked | Missing, malformed, expired, or revoked bearer token. |
| 402 | feature_locked / seat_limit_reached | Plan limit hit. Upgrade or remove a resource to unblock. |
| 403 | forbidden | Authenticated, but role / channel / department scope denies this action. |
| 404 | not_found | Resource does not exist or belongs to another workspace. We never leak existence. |
| 409 | stale_version | expectedVersion mismatch on an optimistic-lock mutation. |
| 409 | conflict | Duplicate resource (e.g., second department with same name). |
| 409 | illegal_status_transition | State machine refused the requested transition. |
| 410 | gone | Resource was deleted. Distinct from 404 so you can stop polling. |
| 422 | semantic_error | Body parses but violates a business rule (e.g., closing a call that was never answered). |
| 429 | rate_limited | See rate-limit section below for caps and retryAfter. |
| 500 | server_error | Unhandled exception on our side. Logged to Sentry. Retry with backoff. |
| 503 | service_unavailable | A dependency (Postgres, Redis) is degraded. Retry with jittered backoff. |
Why 404 instead of 403 on cross-workspace? Returning 403 would confirm that the resource ID exists in a different workspace. 404 is information-hiding by design.
Rate limits
The default cap is 600 requests per minute per authenticated agent. Heavy-traffic endpoints (such as the agent's own /me and /me/workspace) get higher per-route caps to support the dashboard's live polling fallback.
Strict caps on auth endpoints
| Endpoint | Cap | Bucket |
|---|---|---|
POST /api/v1/auth/register | 5/min | per IP |
POST /api/v1/auth/login | 5/min | per email |
POST /api/v1/auth/forgot-password | 3/min | per IP |
POST /api/v1/auth/reset-password | 5/min | per IP |
When you hit a cap
The response is 429 rate_limited with a retryAfter field (seconds until the cap resets). We also return a standard Retry-After header, so any well-behaved HTTP client library will already respect it.
{
"statusCode": 429,
"error": "rate_limited",
"message": "Too many requests",
"retryAfter": 12
}Recommended retry strategy
- Read
retryAfterfrom the body or theRetry-Afterheader. - Sleep that many seconds plus a small random jitter (50 to 300ms) to avoid retry stampedes.
- Cap your total retries at three. If you still see 429, you have an unrelated bug or a tight loop.
- For batch jobs, prefer cursor pagination over parallel fan-out. Two workers at 10 RPS each will outperform 50 workers at 1 RPS while staying under the cap.
Versioning
The major version lives in the URL path: /api/v1/.... We commit to never making a breaking change inside v1. Breaking changes ship as /api/v2/... with a 12-month deprecation window where both versions run side by side.
Optional date pin
For fine-grained pinning inside the v1 timeline, pass an optional header:
Message-Version: 2026-05-13Pinning opts your request into the API behaviour that was current on that date. Useful when a non-breaking enhancement still changes a response field you rely on. The default is latest; we publish the version log in Changelog.
What counts as non-breaking
Inside v1 we reserve the right to:
- Add new endpoints.
- Add new fields to response objects.
- Add new optional request parameters.
- Add new
errorslugs at existing status codes. - Loosen validation (accept inputs we previously rejected).
Anything tighter than this counts as breaking and waits for v2. Treat unknown response fields as forward-compatible; do not enforce JSON strict-mode in your parser.
Deprecation policy
- A deprecated endpoint or field appears in a
Sunsetresponse header and in Changelog. - The minimum deprecation window for v1 surface is 12 months from announcement.
- Real customer migrations get hands-on support during the window; we will not yank a surface out from under you.