A REST API you can read in an afternoon.
Predictable JSON over HTTPS, bearer token auth, signed webhooks and a small JavaScript SDK for the widget. No SOAP, no surprises.
- JSON over HTTPS
- Signed webhooks
- Cursor pagination
Everything the dashboard does is available over the API. Requests are JSON, responses are JSON, and every list endpoint paginates with a cursor. Timestamps are ISO 8601 in UTC, and identifiers are prefixed strings so you always know what you are holding.
Base URL
https://api.chatdrill.com/v1
Auth
Bearer token
Format
JSON, UTF-8
Versioning
In the path
Authentication
Create a secret key under Settings, then Developers, then API keys. Send it as a bearer token on every request. Keys are scoped to a workspace and can be limited to read-only.
curl https://api.chatdrill.com/v1/conversations \
-H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json"Secret keys are server-side only
Never ship sk_live_ keys to a browser or a mobile app. For anything running in a page, use the JavaScript SDK, which authenticates with your public workspace ID instead. Revoking a key takes effect immediately.
Conversations
A conversation is a thread with one contact on one channel. It holds messages, tags, an assignee and a status of open, pending or closed.
/v1/conversationsList and filterstatusstring- open, pending or closed. Omit to return all.
website_idstring- Restrict to a single website in the workspace.
assignee_idstring- Conversations assigned to one agent.
tagstring- Filter by tag. Repeat the parameter to match several.
updated_sinceISO 8601- Useful for incremental syncs.
limitinteger- 1 to 100. Defaults to 20.
cursorstring- Pass next_cursor from the previous page.
GET /v1/conversations?status=open&limit=20
{
"object": "list",
"has_more": true,
"next_cursor": "cnv_8fQ2mR",
"data": [
{
"id": "cnv_7hK2p9",
"status": "open",
"channel": "web",
"subject": "Question about annual billing",
"assignee_id": "usr_3bd1",
"contact_id": "ctc_91xz",
"tags": ["billing"],
"last_message_at": "2026-08-14T09:41:02Z",
"created_at": "2026-08-14T09:38:55Z"
}
]
}/v1/conversationsCreate a conversationCreating a conversation with a contact that does not exist creates the contact too, matched on email or external_id. Routing rules and automation run exactly as they would for a conversation started in the widget.
website_idstring · required- Which website the conversation belongs to.
contactobject · required- email or external_id is required; name and phone are optional.
messageobject · required- body, plus author of contact or agent.
tagsarray- Applied on creation, so routing rules can match them.
attributesobject- Any custom keys. Shown in the agent sidebar.
POST /v1/conversations
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxx
Content-Type: application/json
{
"website_id": "web_1a2b3c",
"contact": {
"email": "dana@example.com",
"name": "Dana Whitfield"
},
"message": {
"body": "My export finished but the file is empty.",
"author": "contact"
},
"tags": ["exports"],
"attributes": {
"plan": "growth",
"source_page": "/settings/export"
}
}HTTP/1.1 201 Created
{
"id": "cnv_7hK2p9",
"status": "open",
"channel": "api",
"contact_id": "ctc_91xz",
"assignee_id": null,
"tags": ["exports"],
"created_at": "2026-08-15T11:02:17Z"
}Contacts
Contacts are people, not conversations. One contact can hold web chat, WhatsApp and email threads at the same time. Use PUT to upsert — it matches on external_id first, then email, then phone.
/v1/contactsCreate or updatePUT /v1/contacts
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxx
Content-Type: application/json
{
"external_id": "user_44120",
"email": "dana@example.com",
"name": "Dana Whitfield",
"phone": "+15551234567",
"attributes": {
"plan": "growth",
"seats": 12,
"signed_up_at": "2026-02-03T00:00:00Z"
}
}/v1/contacts/{id}Retrieve one contact/v1/contacts/{id}/conversationsAll threads for a contact/v1/contacts/{id}Permanent — used for erasure requestsDeletion is not reversible
DELETE /v1/contacts/{id} removes the contact and their conversation history. It is intended for data erasure requests — see the privacy policy for how we handle them.
Messages
Messages belong to a conversation. Set author to contact, agent or ai, and type to reply for something the customer sees or note for an internal note.
/v1/conversations/{id}/messagesSend a messagebodystring · required- Plain text or a limited set of markdown.
authorstring · required- contact, agent or ai.
author_idstring- The agent sending it. Required when author is agent.
typestring- reply (default) or note. Notes are never delivered.
attachmentsarray- Up to five uploaded file IDs.
POST /v1/conversations/cnv_7hK2p9/messages
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxx
Content-Type: application/json
{
"body": "The export is rebuilding now — you will get an email when it is ready.",
"author": "agent",
"author_id": "usr_3bd1",
"type": "reply"
}/v1/conversations/{id}/messagesList messages, oldest firstWebhooks
Register an endpoint under Settings, then Developers, then Webhooks, and choose the events you want. We deliver a POST with a JSON body and retry failures with exponential backoff for 24 hours. Respond with a 2xx quickly and do the work asynchronously.
| Event | Fires when | Key payload fields |
|---|---|---|
conversation.created | A new conversation is opened on any channel. | conversation_id, contact_id, channel, first_message |
conversation.assigned | A conversation is assigned to an agent or a team. | conversation_id, assignee_id, team_id, assigned_by |
conversation.closed | A conversation is marked resolved. | conversation_id, closed_by, duration_seconds, tags |
conversation.reopened | A closed conversation receives a new message. | conversation_id, reopened_at, previous_close_at |
message.created | Any message is sent, by a contact, an agent or the AI Agent. | message_id, conversation_id, author, body, type |
lead.captured | An email or phone number is captured in a conversation. | contact_id, conversation_id, email, phone, source_page |
contact.updated | Contact attributes change through the API, the SDK or an agent edit. | contact_id, changed_fields |
ai.handover | The AI Agent hands a conversation to a human. | conversation_id, reason, confidence, summary |
rating.submitted | A customer submits a satisfaction rating. | conversation_id, score, comment |
Payload shape
POST https://yourapp.com/hooks/chatdrill
X-Chatdrill-Signature: t=1755255737,v1=6f2c9d...
Content-Type: application/json
{
"id": "evt_2mQ81a",
"type": "conversation.created",
"created_at": "2026-08-15T11:02:17Z",
"data": {
"conversation_id": "cnv_7hK2p9",
"website_id": "web_1a2b3c",
"contact_id": "ctc_91xz",
"channel": "web",
"first_message": "My export finished but the file is empty."
}
}Verifying the signature
Every delivery carries an X-Chatdrill-Signature header containing a timestamp and an HMAC SHA-256 of timestamp.rawBody, signed with your endpoint secret. Verify against the raw request body, before any JSON parsing.
import crypto from "node:crypto";
export function verify(rawBody, header, secret) {
const [tsPart, sigPart] = header.split(",");
const timestamp = tsPart.split("=")[1];
const signature = sigPart.split("=")[1];
const expected = crypto
.createHmac("sha256", secret)
.update(timestamp + "." + rawBody)
.digest("hex");
// Constant-time compare, and reject anything older than five minutes.
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
return fresh && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}Deliveries can arrive more than once
Retries mean your handler must be idempotent. Deduplicate on the event id, which is stable across retries of the same event.
JavaScript SDK
The widget snippet defines a single global function. Every call takes a command name and an optional payload. Calls made before the widget finishes loading are queued, so you do not need to wait for a ready event.
Identify the visitor
Identifying a signed-in user links the conversation to a contact, shows account details in the agent sidebar, and lets routing rules use your own attributes.
window.chatdrill("identify", {
id: "user_44120",
email: "dana@example.com",
name: "Dana Whitfield",
created_at: "2026-02-03T00:00:00Z",
attributes: {
plan: "growth",
seats: 12,
mrr: 348
}
});Control the widget
// Open and close the widget
window.chatdrill("open");
window.chatdrill("close");
// Open with a message already typed for the visitor
window.chatdrill("open", { message: "I need help with billing" });
// Show or hide the launcher entirely
window.chatdrill("hide");
window.chatdrill("show");
// Clear the session on logout so the next user starts fresh
window.chatdrill("logout");
// React to widget events
window.chatdrill("on", "conversation:started", function (payload) {
analytics.track("Chat started", { conversationId: payload.id });
});| Command | Payload | What it does |
|---|---|---|
identify | object | Attach a known user to the session. |
open / close | { message? } | Open or close the chat panel, optionally pre-filling a message. |
show / hide | — | Show or hide the launcher, for example on checkout. |
logout | — | Clear the local session so a shared device does not leak history. |
on | event, handler | Subscribe to conversation:started, message:received, widget:opened or widget:closed. |
Installation instructions for every platform, including Shopify and WordPress, are in the Help Center.
Rate limits
Limits are applied per workspace, per minute, across all keys. Every response carries the current state in headers, so you can back off before you are throttled rather than after.
HTTP/1.1 200 OK
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 574
X-RateLimit-Reset: 1755255780| Endpoint group | Limit | Notes |
|---|---|---|
| Reads (GET) | 600 / minute | Use cursors and updated_since rather than re-listing everything. |
| Writes (POST, PUT, DELETE) | 120 / minute | Bulk imports should be spread out or chunked. |
| Webhook deliveries to you | No limit | Retried with backoff for 24 hours if your endpoint fails. |
Exceeding a limit returns 429 Too Many Requests with a Retry-After header in seconds. Honour it — retrying immediately extends the window.
Errors
Errors use standard HTTP status codes and always return the same JSON shape. Log the request_id — it is the fastest way for us to find what happened.
HTTP/1.1 422 Unprocessable Entity
{
"error": {
"type": "invalid_request",
"code": "missing_parameter",
"message": "website_id is required.",
"param": "website_id",
"request_id": "req_5tB0nQ"
}
}| Status | Type | What to do |
|---|---|---|
| 400 | invalid_request | Malformed JSON or an unknown parameter. Fix the request; do not retry. |
| 401 | unauthorized | Missing, revoked or mistyped key. Check the Authorization header. |
| 403 | forbidden | The key is read-only, or the resource belongs to another workspace. |
| 404 | not_found | The ID does not exist, or was deleted. |
| 422 | invalid_request | Valid JSON, invalid values. Read param for the field at fault. |
| 429 | rate_limited | Back off for the number of seconds in Retry-After. |
| 5xx | server_error | Retry with exponential backoff. Persistent failures are reported on the status page. |
Get a key and make your first call.
The Free plan includes API access, so you can build against it before you pay for anything.
No credit card required.