Developers

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

GET/v1/conversationsList and filter
statusstring
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.
Request and response
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"
    }
  ]
}
POST/v1/conversationsCreate a conversation

Creating 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
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"
  }
}
Response
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.

PUT/v1/contactsCreate or update
PUT /v1/contacts
PUT /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"
  }
}
GET/v1/contacts/{id}Retrieve one contact
GET/v1/contacts/{id}/conversationsAll threads for a contact
DELETE/v1/contacts/{id}Permanent — used for erasure requests

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

POST/v1/conversations/{id}/messagesSend a message
bodystring · 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/{id}/messages
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"
}
GET/v1/conversations/{id}/messagesList messages, oldest first

Webhooks

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.

EventFires whenKey payload fields
conversation.createdA new conversation is opened on any channel.conversation_id, contact_id, channel, first_message
conversation.assignedA conversation is assigned to an agent or a team.conversation_id, assignee_id, team_id, assigned_by
conversation.closedA conversation is marked resolved.conversation_id, closed_by, duration_seconds, tags
conversation.reopenedA closed conversation receives a new message.conversation_id, reopened_at, previous_close_at
message.createdAny message is sent, by a contact, an agent or the AI Agent.message_id, conversation_id, author, body, type
lead.capturedAn email or phone number is captured in a conversation.contact_id, conversation_id, email, phone, source_page
contact.updatedContact attributes change through the API, the SDK or an agent edit.contact_id, changed_fields
ai.handoverThe AI Agent hands a conversation to a human.conversation_id, reason, confidence, summary
rating.submittedA customer submits a satisfaction rating.conversation_id, score, comment

Payload shape

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

Node.js
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.

identify
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

Commands and events
// 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 });
});
CommandPayloadWhat it does
identifyobjectAttach a known user to the session.
open / close{ message? }Open or close the chat panel, optionally pre-filling a message.
show / hideShow or hide the launcher, for example on checkout.
logoutClear the local session so a shared device does not leak history.
onevent, handlerSubscribe 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.

Response headers
HTTP/1.1 200 OK
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 574
X-RateLimit-Reset: 1755255780
Endpoint groupLimitNotes
Reads (GET)600 / minuteUse cursors and updated_since rather than re-listing everything.
Writes (POST, PUT, DELETE)120 / minuteBulk imports should be spread out or chunked.
Webhook deliveries to youNo limitRetried 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.

Error response
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"
  }
}
StatusTypeWhat to do
400invalid_requestMalformed JSON or an unknown parameter. Fix the request; do not retry.
401unauthorizedMissing, revoked or mistyped key. Check the Authorization header.
403forbiddenThe key is read-only, or the resource belongs to another workspace.
404not_foundThe ID does not exist, or was deleted.
422invalid_requestValid JSON, invalid values. Read param for the field at fault.
429rate_limitedBack off for the number of seconds in Retry-After.
5xxserver_errorRetry 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.