REST API
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
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 |
|---|---|---|
|
| 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. |
|
| Long-lived per-workspace key for backend integrations. Mint at Settings → API → Keys. Opens a deliberately small set of endpoints — see below. |
|
| Short-lived visitor token minted by |
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_bycolumns, 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 with403 forbiddenand 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/eventsNote 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/changelogendpoint 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 |
|---|---|---|
|
| Items per page. Min 1, max 100. We may return fewer. Default: |
|
| Pass |
|
| A single column name, |
|
| Sparse fieldsets — |
|
| 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:
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.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.If found and body differs, return
409 idempotency_conflict.If a first request with that key is still running, return
409 conflict.If not found, execute, cache the response, return it.
See Errors + idempotency for the deep dive.
Common headers
Request headers
Name | Type | Description |
|---|---|---|
|
| One of the three auth schemes above. |
|
| 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 |
|---|---|---|
|
| Cap for that bucket. The suffix is the bucket name — |
|
| Remaining budget in that bucket. |
|
| Seconds until the window rolls — not a unix timestamp. |
|
| On a 429, seconds to wait. Also suffixed; there is no plain |
|
| 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 readAccept-Languageor 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-01Operators: 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/conversationspredates 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. Sendingfilter[]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 |
|---|---|
| 30 / sec, per route, per IP |
| 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 |
|---|---|---|
|
| Server-side Node.js client with retries, webhook verification, idempotency helpers. See sdk-node. |
|
| 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 withGET /v1/conversationsand 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.