API Docs

Errors, idempotency, and rate limits

AdminUpdated Sep 19, 2026

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

code (required)

string

Stable machine-readable identifier. Match on this; never match on message.

message (required)

string

Human-readable message. May change between releases — fine for logging, not fine for branching.

details

object

Structured context. Shape varies by code — validation errors carry issues (the raw zod issue array); rate-limit errors carry retryAfterSeconds. Absent on codes that have nothing to add.

Codes

Error code reference

Name

Type

Description

validation_failed

400

The request body or query parameters failed zod parsing. details.issues is the raw zod issue array — each entry has a path naming the offending field.

unauthenticated

401

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 POST /v1/auth/refresh.

invalid_credentials

401

Login attempt with wrong password or 2FA code.

two_factor_required

401

Email + password matched, but TOTP / WebAuthn challenge is pending. details.challengeId is what you submit to POST /v1/auth/2fa/challenge.

forbidden

403

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.

workspace_suspended

403

The workspace is suspended. Every write is refused until it is restored.

not_found

404

The resource doesn't exist or isn't in the authenticated workspace. Indistinguishable on purpose — leaking which would help an attacker enumerate IDs.

conflict

409

The write collides with existing state. details shape varies by the endpoint that raised it.

idempotency_conflict

409

You replayed an idempotency key with a different request body. See Idempotency below.

slug_taken

409

The slug you asked for is already used in this workspace.

not_configured

409

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.

seat_limit_exceeded

402

The workspace is at its seat cap for its plan.

conversation_cap_exceeded

402

The workspace is at its conversation cap for its plan.

workspace_limit_reached

402

The account is at its workspace cap.

precondition_failed

428

A write endpoint that requires Idempotency-Key was called without one. Only enforced when the API runs with NODE_ENV=production. A handful of bot endpoints reuse this code with a 412 for their own preconditions.

payload_too_large

413

Body exceeds the per-route limit.

unsupported_media_type

415

Wrong Content-Type. Most endpoints want application/json.

unprocessable

422

Request was valid but could not be carried out — e.g. a social-login profile we cannot link to an account.

rate_limited

429

You hit a rate-limit bucket. details.retryAfterSeconds is seconds to wait.

invariant_violation

500

A bug on our side. Not retryable; report it.

unknown

500

An unhandled exception. The message is always the literal "Internal server error" — nothing about the cause crosses the boundary.

dependency_failure

502

An upstream provider failed. The provider is named in message; details may carry a closed-vocabulary delivery reason. Safe to retry.

service_unavailable

503

We're degraded or in a planned-maintenance window.

ai_unavailable

503

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. conflict and idempotency_conflict are 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:

  1. 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.

  2. If found and the request body hash matches → return the cached status and body, with Idempotency-Replay: true on the response. Response headers from the original call are not replayed.

  3. If found and body differs → 409 idempotency_conflict.

  4. If a first request with the same key is still in flight → 409 conflict. Retry once it settles.

  5. If not found → execute the request, cache the response.

Parameters

Name

Type

Description

Idempotency-Key

string

A UUID, or any opaque token of 16–128 characters drawn from the unreserved + sub-delims URI set. Anything else is rejected as validation_failed. UUID v4 or v7 recommended.

Tip — Set it for every retryable POST

Even if your client retries are off, transient network glitches mean a write can land twice. Idempotency-Key turns 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

limit

integer

Max items per page. Capped at 100, minimum 1. Default: 25.

cursor

string

Pass nextCursor from the previous response. Omit on first call.

sort

string

A single column name, optionally prefixed with - for descending. One column only — comma-separated lists are not parsed. Column names are camelCase and each endpoint allows only its own list. Default: -createdAt.

filter[]

string, repeatable

Generic filter clauses, field:op:value (or field:value for an implicit eq): ?filter[]=status:open&filter[]=createdAt:gte:2026-05-01. Max 32 clauses, and repeating a field is rejected — use in. Operators: eq, ne, in, gt, gte, lt, lte.

fields

csv

Sparse fieldsets to reduce payload size: fields=id,subject,status. Unknown fields are a 400, not a silent drop.

Warning — The inbox listing does not use filter[]

GET /v1/conversations predates 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. The filter[] 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

short

30 / sec

per route, per IP

login

100 / 15 min

per route, per IP

The rest are opt-in and only run on the routes that declare them:

Bucket

Limit

Routes

auth-login

20 / min

POST /v1/auth/login

auth-register

5 / min

POST /v1/auth/register, POST /v1/auth/guest

auth-forgot

5 / min

POST /v1/auth/password/forgot

auth-reset

10 / min

POST /v1/auth/password/reset

auth-2fa-challenge

10 / min

POST /v1/auth/2fa/challenge

portal-request

10 / min

POST /v1/portal/auth/request, .../auth/password

invites-lookup

30 / min

GET /v1/invites/lookup

invites-accept

10 / min

POST /v1/invites/accept

platform-provision

20 / min

POST /v1/platform/workspaces, .../users, .../bots

platform-login-link

30 / min

POST /v1/platform/login_links

platform-login-redeem

20 / min

POST + GET /v1/platform/login/redeem

bot-reply

60 / min

POST /v1/bot/conversations/{id}/messages

Warning — Rate-limit headers are suffixed with the bucket name

There is no plain X-RateLimit-Limit and no plain Retry-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

-Reset is seconds until the window rolls, not a unix timestamp. On a 429 the wait is in Retry-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?

GET

Yes, with exponential backoff

DELETE

Yes (idempotent by definition)

POST

Only with Idempotency-Key, and only on a route that accepts one

PATCH

Only with Idempotency-Key, and only on a route that accepts one

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()
Was this page helpful?
Errors, idempotency, and rate limits