Errors, idempotency, and rate limits
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
Errors, idempotency, and rate limits
Every error code, retry strategy, and rate-limit class — with examples.
This is the doc you keep open while debugging an integration. It documents the shape of every error, the codes you'll encounter, idempotency contract, pagination, and rate-limit budgets.
Error envelope
Every non-2xx response has the same JSON shape:
{
"error": {
"code": "validation_failed",
"message": "Field 'email' must be a valid email",
"details": {
"issues": [
{ "code": "invalid_string", "path": ["email"], "message": "Invalid email" }
]
}
}
}Parameters
Name | Type | Description |
|---|---|---|
|
| Stable machine-readable identifier. Match on this; never match on |
|
| Human-readable message. May change between releases — fine for logging, not fine for branching. |
|
| Structured context. Shape varies by code — validation errors carry |
Codes
Error code reference
Name | Type | Description |
|---|---|---|
|
| The request body or query parameters failed zod parsing. |
|
| Bearer token is missing, malformed, expired, or signed by a key we don't recognize. An expired access token lands here — mint a new one with |
|
| Login attempt with wrong password or 2FA code. |
|
| Email + password matched, but TOTP / WebAuthn challenge is pending. |
|
| The caller authenticated, but RBAC says they can't do this action. Also returned when a workspace API key is presented to an endpoint that requires a signed-in user — see Authentication. |
|
| The workspace is suspended. Every write is refused until it is restored. |
|
| The resource doesn't exist or isn't in the authenticated workspace. Indistinguishable on purpose — leaking which would help an attacker enumerate IDs. |
|
| The write collides with existing state. |
|
| You replayed an idempotency key with a different request body. See Idempotency below. |
|
| The slug you asked for is already used in this workspace. |
|
| The workspace has not configured something this call needs — a BYOK provider key, an integration install. Deliberately not a 5xx: it is fixable in your settings, not by us. |
|
| The workspace is at its seat cap for its plan. |
|
| The workspace is at its conversation cap for its plan. |
|
| The account is at its workspace cap. |
|
| A write endpoint that requires |
|
| Body exceeds the per-route limit. |
|
| Wrong |
|
| Request was valid but could not be carried out — e.g. a social-login profile we cannot link to an account. |
|
| You hit a rate-limit bucket. |
|
| A bug on our side. Not retryable; report it. |
|
| An unhandled exception. The message is always the literal |
|
| An upstream provider failed. The provider is named in |
|
| We're degraded or in a planned-maintenance window. |
|
| No AI provider is reachable for this workspace right now. |
Warning — Don't branch on HTTP status alone
Two errors can share an HTTP status but differ in
code.conflictandidempotency_conflictare both 409 but mean very different things — only one is safe to retry.
Idempotency
Idempotency-Key is not accepted on every write — it is opt-in per route, and on the routes that opt in it is required when the API runs with NODE_ENV=production. Calling one of those endpoints without the header returns 428 precondition_failed. Routes that have not opted in ignore the header entirely.
POST /v1/conversations/{id}/messages — Bearer token
POST /v1/conversations/01H7.../messages
Authorization: Bearer …
Idempotency-Key: 9f1b6d2a-3c7d-4f4b-bf3c-1d6b1a8e9d4b
Content-Type: application/json
{ "body": "Hello", "kind": "text" }What we do server-side:
Look up
(workspace, user, method, path, idempotency_key)in a 24-hour Redis cache. The method and path are part of the key, so the same key sent to a different endpoint is a different slot, not a replay. Under an API key the user half is a constant.If found and the request body hash matches → return the cached status and body, with
Idempotency-Replay: trueon the response. Response headers from the original call are not replayed.If found and body differs → 409
idempotency_conflict.If a first request with the same key is still in flight → 409
conflict. Retry once it settles.If not found → execute the request, cache the response.
Parameters
Name | Type | Description |
|---|---|---|
|
| A UUID, or any opaque token of 16–128 characters drawn from the unreserved + sub-delims URI set. Anything else is rejected as |
Tip — Set it for every retryable POST
Even if your client retries are off, transient network glitches mean a write can land twice.
Idempotency-Keyturns retries from "corrupt my data" into "no-op."
Pagination
List endpoints use cursor pagination. A cursor is a base64url-encoded keyset position. It does not expire, but it is only meaningful against the same sort — pass back what we gave you rather than constructing one.
GET /v1/conversations?limit=50&cursor=eyJ... — Bearer token
{
"items": [ ... ],
"nextCursor": "eyJpZCI6IjAxSDguLi4ifQ"
}nextCursor is null on the last page. Endpoints built on the shared paginator also return hasMore; the inbox listing above does not — treat a null cursor as the end condition and you are correct either way.
Parameters
Name | Type | Description |
|---|---|---|
|
| Max items per page. Capped at 100, minimum 1. Default: |
|
| Pass |
|
| A single column name, optionally prefixed with |
|
| Generic filter clauses, |
|
| Sparse fieldsets to reduce payload size: |
Warning — The inbox listing does not use filter[]
GET /v1/conversationspredates the generic filter parser and takes flat named parameters instead —?status=open&assignee=…&channel=…, plus a JSON?filter=group tree when you need OR or nesting. Thefilter[]syntax above applies to the endpoints that opt into the shared paginator.
Rate limits
Buckets are per route, not shared across an API surface: the counter key is derived from the handler as well as the bucket name, so two endpoints sharing a bucket name do not share a budget. Every bucket is keyed by client IP — there are no per-workspace or per-user buckets.
Two buckets apply to every request:
Bucket | Limit | Scope |
|---|---|---|
| 30 / sec | per route, per IP |
| 100 / 15 min | per route, per IP |
The rest are opt-in and only run on the routes that declare them:
Bucket | Limit | Routes |
|---|---|---|
| 20 / min |
|
| 5 / min |
|
| 5 / min |
|
| 10 / min |
|
| 10 / min |
|
| 10 / min |
|
| 30 / min |
|
| 10 / min |
|
| 20 / min |
|
| 30 / min |
|
| 20 / min |
|
| 60 / min |
|
Warning — Rate-limit headers are suffixed with the bucket name
There is no plain
X-RateLimit-Limitand no plainRetry-After. Every bucket in this API is a named throttler, so the header carries the name:X-RateLimit-Limit-short: 30 X-RateLimit-Remaining-short: 27 X-RateLimit-Reset-short: 1 X-RateLimit-Limit-login: 100 X-RateLimit-Remaining-login: 96 X-RateLimit-Reset-login: 812
-Resetis seconds until the window rolls, not a unix timestamp. On a 429 the wait is inRetry-After-<bucket>.
On a 429 from a bucket:
HTTP/1.1 429 Too Many Requests
Retry-After-short: 1
Content-Type: application/json
{ "error": { "code": "rate_limited", "message": "ThrottlerException: Too Many Requests" } }Note the absent details. Two endpoints — the login lockout and the conversation read-state limiter — raise rate_limited themselves and do carry details.retryAfterSeconds. Everything else gives you only the suffixed header.
Retries
Method | Retry? |
|---|---|
| Yes, with exponential backoff |
| Yes (idempotent by definition) |
| Only with |
| Only with |
There is no If-Match / ETag support anywhere in this API, and no HEAD routes — conditional requests are not a retry strategy here.
Recommended backoff: min(2^attempt * 1s, 60s) with ±30% jitter, max 5 attempts.
async function retryable<T>(call: () => Promise<T>, max = 5): Promise<T> {
for (let i = 0; i < max; i++) {
try {
return await call();
} catch (e) {
const status = (e as { status?: number }).status;
// `retryAfterSeconds` — not `retryAfter`. Only the login-lockout and
// read-state limiters populate it; a bucket 429 carries no details at
// all and falls through to the exponential term below.
const retryAfter = (e as { details?: { retryAfterSeconds?: number } }).details
?.retryAfterSeconds;
const transient = status === 429 || (status !== undefined && status >= 500 && status <= 599);
if (!transient || i === max - 1) throw e;
const baseMs = retryAfter !== undefined ? retryAfter * 1000 : Math.min(1000 * 2 ** i, 60_000);
const jitter = Math.random() * 0.3 * baseMs;
await new Promise((r) => setTimeout(r, baseMs + jitter));
}
}
throw new Error('unreachable');
}Examples
Upserting a contact, with all the right headers. POST /v1/contacts is one of the endpoints a workspace API key may call — see Authentication for the full list.
curl
curl -X POST https://api.chatlychat.com/v1/contacts \
-H "Authorization: Bearer $CHATLY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "externalId": "u_42", "email": "[email protected]", "name": "Jamie" }'Node SDK
import { ChatlyClient } from '@livechat/sdk-node';
const lc = new ChatlyClient({
baseUrl: 'https://api.chatlychat.com',
apiKey: process.env.CHATLY_API_KEY!,
});
const contact = await lc.contacts.upsert({
externalId: 'u_42',
email: '[email protected]',
name: 'Jamie',
});Python
import requests, uuid
headers = {
"Authorization": f"Bearer {token}",
"Idempotency-Key": str(uuid.uuid4()),
"Content-Type": "application/json",
}
r = requests.post(
"https://api.chatlychat.com/v1/contacts",
headers=headers,
json={"externalId": "u_42", "email": "[email protected]", "name": "Jamie"},
timeout=10,
)
r.raise_for_status()
contact = r.json()