API Docs

REST API

AdminUpdated Sep 19, 2026

REST API

The /v1/* surface — authentication, versioning, pagination, idempotency, filters, and the OpenAPI spec.

The Chatly REST API is the canonical interface to your workspace — everything you can do in the dashboard you can do here, and most of what the dashboard does is built on top of these endpoints.

Info — Base URL + version

Base URL: https://api.chatlychat.com/v1 (swap the host for your self-hosted instance).

Versioning: URI-prefixed. The current version is v1. We commit to a 12-month deprecation window before removing a versioned route.

Authentication

Three authentication schemes, each scoped to its use case:

Auth schemes

Name

Type

Description

Bearer access token

Authorization: Bearer …

A signed-in user's session token. Mint via password login or SSO. This is what the dashboard uses, and what all but a handful of endpoints require.

API key

Authorization: Bearer ck_…

Long-lived per-workspace key for backend integrations. Mint at Settings → API → Keys. Opens a deliberately small set of endpoints — see below.

Widget token

Authorization: Bearer …

Short-lived visitor token minted by POST /v1/widget/init. Only valid for widget endpoints.

Both token kinds travel in the same Authorization: Bearer header; we tell them apart by shape, because an API key is self-identifying (ck_live_…) and a JWT is not. Both carry the workspace scope — we infer workspace_id from them on every request. There is no X-Workspace-Id header.

Warning — An API key does not open the whole API

Most endpoints in this product were written assuming a human is behind the request — they stamp a user id into created_by columns, or read the acting agent's identity to decide what they may see. A key has no user, so a key is admitted only where somebody has decided it should be. Everywhere else it is refused with 403 forbidden and the message "This endpoint requires a signed-in user; API keys cannot be used here." — a valid key, a closed door, said plainly rather than as a mysterious 401.

These are the endpoints a ck_… key may call today:

GET    /v1/api-keys/whoami

GET    /v1/contacts
POST   /v1/contacts
POST   /v1/contacts/bulk
GET    /v1/contacts/{id}
GET    /v1/contacts/{id}/journey
GET    /v1/contacts/{id}/mood
POST   /v1/contacts/{id}/tags
DELETE /v1/contacts/{id}/tags/{tag}
POST   /v1/contacts/{id}/unsubscribe
POST   /v1/contacts/{id}/merge

GET    /v1/conversations
GET    /v1/conversations/unattended
GET    /v1/conversations/{id}
GET    /v1/conversations/{id}/linked
GET    /v1/conversations/{id}/messages
POST   /v1/conversations/{id}/messages

GET    /v1/kb/articles
GET    /v1/kb/articles/{slug}
GET    /v1/kb/collections

GET    /v1/search
POST   /v1/cdp/events

Note what is not there: closing or assigning a conversation, editing a message, blocking a contact, and every settings, reporting, workflow, billing and admin route. Those need a signed-in user.

Versioning + stability

  • All endpoints live under /v1/.

  • Breaking changes get a new major version (/v2/). Old version stays live for 12 months minimum.

  • Non-breaking additions (new optional fields, new endpoints) happen in-place.

  • We publish a changelog on this site at /changelog, with an RSS feed. It is site content, not an API resource — there is no /v1/changelog endpoint to poll.

URI scheme

GET    /v1/{resource}              # list
GET    /v1/{resource}/{id}         # fetch one
POST   /v1/{resource}              # create
PATCH  /v1/{resource}/{id}         # partial update
PUT    /v1/{resource}/{id}         # full replace (rare; we prefer PATCH)
DELETE /v1/{resource}/{id}         # delete

POST   /v1/{resource}/{id}/{verb}  # action (e.g. /conversations/{id}/close)

Resources are plural nouns. Verbs only show up for actions that don't fit CRUD (e.g. close, reopen, assign, merge).

Pagination

List endpoints use cursor pagination. A cursor is a base64url-encoded keyset position; 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 right either way.

Pagination params

Name

Type

Description

limit

integer

Items per page. Min 1, max 100. We may return fewer. Default: 25.

cursor

string

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

sort

string

A single column name, - prefixed for descending. One column only — a comma-separated list is not parsed. Names are camelCase, and each endpoint allows only its own list; an unknown column is a 400. Default: -createdAt.

fields

csv

Sparse fieldsets — fields=id,subject,status. Reduces payload size. An unknown field is a 400, not a silent drop.

filter[]

string, repeatable

Filter clauses — see Filters below.

Tip — Don't paginate offsets you computed yourself

A cursor encodes the keyset position for one particular sort, so it is only meaningful in the sequence that produced it. If you need random access into a large list, use a filtered query instead of seeking to a specific page.

Idempotency

Idempotency-Key is opt-in per route, and on the routes that opt in it is required in production — calling one without the header returns 428 precondition_failed. Routes that have not opted in ignore it.

POST /v1/conversations/{id}/messages
Authorization: Bearer …
Idempotency-Key: 9f1b6d2a-3c7d-4f4b-bf3c-1d6b1a8e9d4b
Content-Type: application/json

{ "body": "Hello", "kind": "text" }

Server behavior:

  1. Look up (workspace, user, method, path, idempotency_key) in Redis (24h TTL). Method and path are part of the key, so the same key on a different endpoint is a different slot, not a replay.

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

  3. If found and body differs, return 409 idempotency_conflict.

  4. If a first request with that key is still running, return 409 conflict.

  5. If not found, execute, cache the response, return it.

See Errors + idempotency for the deep dive.

Common headers

Request headers

Name

Type

Description

Authorization

required

One of the three auth schemes above.

Idempotency-Key

conditional

Required in production on write endpoints that declare it; ignored elsewhere. A UUID, or any opaque token of 16–128 characters from the unreserved + sub-delims URI set.

Response headers

Name

Type

Description

X-RateLimit-Limit-{bucket}

integer

Cap for that bucket. The suffix is the bucket name — -short and -login on every response, plus any opt-in bucket the route declares. There is no unsuffixed form.

X-RateLimit-Remaining-{bucket}

integer

Remaining budget in that bucket.

X-RateLimit-Reset-{bucket}

integer

Seconds until the window rolls — not a unix timestamp.

Retry-After-{bucket}

integer

On a 429, seconds to wait. Also suffixed; there is no plain Retry-After.

Idempotency-Replay

true

Present when this response came from the idempotency cache rather than from executing the request.

Warning — No request-id header

This API does not read or emit X-Request-Id, and does not read Accept-Language or an import-replay header. If you need to correlate a call with your own logs, key on the idempotency key you sent.

Filters

Endpoints built on the shared paginator take repeated filter[] clauses of the form field:op:value (or field:value for an implicit eq):

?filter[]=status:open
?filter[]=assigneeUserId:01H7...
?filter[]=priority:in:high,urgent
?filter[]=createdAt:gte:2026-05-01

Operators: eq (default), ne, in, gt, gte, lt, lte. There is no substring or regex operator — matching is exact, or a range, or a set.

The first : splits the field from the rest, which is what lets an ISO-8601 value keep its own colons (createdAt:gte:2026-06-28T12:00:00Z). in takes a comma-separated list. At most 32 clauses per request, and repeating the same field is rejected — use in instead. Every field is checked against the endpoint's own allowlist, so a filter naming a column the endpoint does not offer is a 400 rather than a query.

Warning — The inbox listing is the exception

GET /v1/conversations predates this parser and takes flat named parameters instead — ?status=open&assignee=…&channel=…&tags=… — plus a JSON ?filter= group tree when you need OR or nesting. Sending filter[] clauses to it does nothing.

Errors

Every non-2xx response has the same JSON shape:

{
  "error": {
    "code": "validation_failed",
    "message": "Field 'email' must be a valid email",
    "details": { "field": "email" }
  }
}

Match on error.code — never on error.message (we may rephrase between releases). Full reference: Errors + idempotency.

Rate limits

Every bucket is keyed by the client IP, and the counter key is derived from the handler as well as the bucket name — so limits are per route, and two routes sharing a bucket name do not share a budget. There are no per-workspace or per-user buckets.

Two buckets apply to every request:

Bucket

Limit

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 credential, invite, platform and bot endpoints that declare them — the full table is in Errors + idempotency.

OpenAPI

The OpenAPI 3.1 spec is auto-generated from the controllers and zod schemas. No hand-maintained spec → no drift between docs and behavior.

  • Browse: https://api.chatlychat.com/docs (Swagger UI)

  • Raw spec: https://api.chatlychat.com/docs-json (or /docs-yaml)

The spec includes every endpoint we ship, with full request/response schemas, examples, and error codes. Use it to generate clients in your language of choice with openapi-typescript, oapi-codegen, swagger-codegen, etc.

Client libraries

Official SDKs

Name

Type

Description

@livechat/sdk-node

TypeScript

Server-side Node.js client with retries, webhook verification, idempotency helpers. See sdk-node.

@livechat/sdk-js

TypeScript

Browser + edge-runtime safe client. See sdk-js.

There are no first-party SDKs beyond these two. In any other language, generate a client from the OpenAPI spec above.

Examples

Upserting a contact and posting a message into an existing conversation — both endpoints a workspace API key may call.

Warning — There is no create-conversation endpoint

A conversation is created by the channel it arrives on — the widget, an inbound email, a WhatsApp message — not by an API call. There is no POST /v1/conversations. To reach an existing thread from your backend, list with GET /v1/conversations and post into the one you want.

curl

TOKEN="$CHATLY_API_KEY"
BASE="https://api.chatlychat.com/v1"

# 1. Upsert the contact
CONTACT=$(curl -s -X POST "$BASE/contacts" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "externalId": "u_42", "email": "[email protected]", "name": "Jamie" }')
CONTACT_ID=$(echo "$CONTACT" | jq -r .id)

# 2. Find their open conversation
CONV_ID=$(curl -s "$BASE/conversations?status=open&limit=1" \
  -H "Authorization: Bearer $TOKEN" | jq -r '.items[0].id')

# 3. Post a message into it
curl -X POST "$BASE/conversations/$CONV_ID/messages" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "body": "Hi! I noticed an issue with my billing.", "kind": "text" }'

Python

import os, uuid, requests

TOKEN = os.environ["CHATLY_API_KEY"]
BASE = "https://api.chatlychat.com/v1"
HEAD = lambda: {
    "Authorization": f"Bearer {TOKEN}",
    "Idempotency-Key": str(uuid.uuid4()),
    "Content-Type": "application/json",
}

contact = requests.post(f"{BASE}/contacts", headers=HEAD(), json={
    "externalId": "u_42", "email": "[email protected]", "name": "Jamie"
}).json()

page = requests.get(
    f"{BASE}/conversations", headers=HEAD(), params={"status": "open", "limit": 1}
).json()
conv_id = page["items"][0]["id"]

requests.post(f"{BASE}/conversations/{conv_id}/messages", headers=HEAD(), json={
    "body": "Hi! I noticed an issue with my billing.", "kind": "text",
})

Observability

The API runs OpenTelemetry auto-instrumentation for HTTP, Postgres and Redis, so every request produces a span with the standard semantic attributes — http.method, http.route, http.status_code — and the database and cache calls beneath it appear as children.

Tracing is exported only when the deployment sets OTEL_EXPORTER_OTLP_ENDPOINT; with it unset, instrumentation is a deliberate no-op so a self-hosted install boots without a collector.

There is no request-id header to quote back to us. When you report a problem, the endpoint, an approximate timestamp, and the idempotency key you sent are what let us find the trace.

Was this page helpful?
REST API