Deskwoot REST API
Build integrations, automate workflows, and manage your entire Deskwoot account programmatically. All endpoints use JSON and return standard HTTP status codes.
Base URL: https://deskwoot.com/api/v1
On this page
- Authentication
- Errors
- Conversations
- Messages
- Inbound messages
- WhatsApp templates
- Tickets
- Contacts
- Organizations
- Inboxes
- Agents
- Teams
- Labels
- Canned Responses
- Macros
- Automation Rules
- Campaigns
- Webhooks
- API Tokens
- Bots
- Custom Attributes
- Custom Roles
- Reports
- SLA Policies
- Business Hours
- Help Center
- Account Settings
- Notifications
- Audit Logs
- Billing
- Rate limits
- Need help?
Authentication
The Deskwoot API uses bearer token authentication. API tokens can be created in your account settings under Settings → API Tokens. API access requires the Enterprise plan.
Include your API token in the Authorization header of every request:
curl https://deskwoot.com/api/v1/accounts/{account_id}/contacts \
-H "Authorization: Bearer dw_your_api_token_here" \
-H "Content-Type: application/json"Finding your account ID
Most endpoints live under /v1/accounts/{accountId}/..., so you need your account ID. It is shown in Settings → API Tokens next to the usage examples. To fetch it programmatically from a token, call GET /v1/me, the one endpoint that does not need an account ID in the path:
curl https://deskwoot.com/api/v1/me \
-H "Authorization: Bearer dw_your_api_token_here"
# Response
{
"user": { "id": "...", "email": "you@example.com", "name": "..." },
"accountId": "cmxxxxxxxxxxxxxxxxxxxxxxx",
"accountIds": ["cmxxxxxxxxxxxxxxxxxxxxxxx"],
"authMethod": "api_token",
"scopes": ["read", "write"]
}Token format
All API tokens start with the prefix dw_ followed by 64 hex characters. Tokens are shown only once at creation time. Store them securely.
Scopes
Each token has one or more scopes that control which resources it can access. The broad read and write scopes cover all resources except deleting conversations, commerce actions and inbound ingest: those scopes must be granted on their own. Use narrow scopes so each token reaches only what it needs.
| Scope | Access |
|---|---|
| read | Read access to all resources |
| write | Write access to all resources |
| contacts:read | Read contacts only |
| contacts:write | Create and update contacts |
| conversations:read | Read conversations and messages |
| conversations:write | Update conversations and send messages |
| conversations:delete | Delete conversations in bulk. Granting it needs the delete permission |
| help_center:read | Read portals, articles, and categories |
| help_center:write | Create and update portals, articles, and categories |
| inboxes:read | List the account's inboxes |
| settings:read | Read custom attribute definitions |
| drafts:read | Read reply drafts waiting for approval |
| drafts:write | Propose reply drafts for an agent to approve |
| commerce:read | Look up orders, payments and customers in connected stores |
| commerce:refund | Refund orders. Granting it needs the refund permission |
| commerce:cancel | Cancel orders and subscriptions. Granting it needs the cancel permission |
| commerce:fulfill | Mark orders as fulfilled. Granting it needs the fulfill permission |
| inbound:ingest | Send inbound customer messages into a REST API channel inbox |
Errors
The API returns standard HTTP status codes. Errors include a JSON body with an error field describing the problem.
| Status | Meaning | Description |
|---|---|---|
| 200 | OK | Request succeeded |
| 201 | Created | Resource created |
| 400 | Bad Request | Invalid parameters or missing required fields |
| 401 | Unauthorized | Missing or invalid API token |
| 403 | Forbidden | Token lacks required scope or feature not available on your plan |
| 404 | Not Found | Resource does not exist |
| 409 | Conflict | Resource already exists (e.g. duplicate email) |
| 500 | Server Error | Unexpected error on our side |
{
"error": "Label with this title already exists"
}Conversations
/v1/accounts/{accountId}/conversationsList conversations, newest activity first unless sort=oldest. Without type it lists customer conversations only, never tickets. Each row carries its category, and needsAttention: true while a team has it, nobody holds it, it is open or pending and the AI bot is not answering it. A conversation that waits in the round robin queue (queuedAt set) never needs attention, since the queue hands it to an agent on its own. While Jira is connected and tickets are on, a ticket with linked Jira issues also carries linkedIssues (the first 3: key, url, statusName, statusCategory, deleted, notFound) and linkedIssueCount. Paged by page and pageSize (meta.total counts every match), or by cursor: cursor=start, then meta.nextCursor while meta.hasMore.
Auth: Bearer token (conversations:read) or session
| Parameter | Type | Required | Description |
|---|---|---|---|
| status | string | Optional | OPEN, PENDING, RESOLVED, SNOOZED or SPAM, comma separated, or ALL for every status but SPAM (default OPEN,PENDING) |
| type | string | Optional | conversations (default), tickets or all |
| category_id | string | Optional | A category id (see Tickets, Categories) |
| ticket_status_id | string | Optional | A custom ticket status id (see Tickets, Ticket statuses) |
| priority | string | Optional | URGENT, HIGH, MEDIUM or LOW, comma separated |
| assignee_id | string | Optional | An agent id, me, or unassigned |
| team_id | string | Optional | A team id |
| created_by | string | Optional | me: only the tickets you created (lists tickets whatever the type) |
| tracker | string | Optional | only: just the trackers; exclude: everything but them (see Tickets, Trackers). Trackers are tickets, so only lists something with type tickets or all |
| issue_key | string | Optional | A Jira issue key such as TAB-12, in any case: only the tickets linked to that issue (see Tickets, Linked Jira issues), so it lists something with type tickets or all. 400 when it is no issue key. search finds tickets by an issue key too |
| inbox_id | string | Optional | An inbox id |
| label_id | string | Optional | Label ids, comma separated |
| contact_id | string | Optional | A contact id |
| attr[<key>] | string | Optional | A conversation custom attribute that must equal the value, for example attr[order_id]=1001. The value also finds the number or true/false it spells. Case sensitive, at most 5 keys, each once (else 400) |
| search | string | Optional | Free text |
| sort | string | Optional | newest (default) or oldest |
| page | number | Optional | Page number (1 to 100) |
| pageSize | number | Optional | Rows per page (1 to 100, default 25) |
| cursor | string | Optional | start, then meta.nextCursor |
/v1/conversations/{conversationId}Retrieve a single conversation with its contact, inbox, assignee, team, labels, and messages.
Auth: Bearer token (conversations:read) or session
{
"id": "clx1abc...",
"status": "OPEN",
"priority": "MEDIUM",
"inboxId": "clx2def...",
"contactId": "clx3ghi...",
"assigneeId": "clx4jkl...",
"teamId": null,
"muted": false,
"lastActivityAt": "2026-03-14T10:30:00Z",
"createdAt": "2026-03-13T08:00:00Z",
"contact": { "id": "...", "name": "Lisa", "email": "lisa@example.com" },
"inbox": { "id": "...", "name": "Live Chat", "channelType": "WEB" },
"assignee": { "id": "...", "name": "Anna", "email": "anna@example.com" },
"labels": [{ "id": "...", "title": "VIP", "color": "#6366F1" }],
"messages": [...]
}/v1/conversations/{conversationId}Update a conversation's status, priority, assignee, team, or custom attributes.
Auth: Bearer token (conversations:write) or session
| Parameter | Type | Required | Description |
|---|---|---|---|
| status | string | Optional | OPEN, RESOLVED, PENDING, or SNOOZED |
| priority | string | null | Optional | URGENT, HIGH, MEDIUM, LOW, or null to clear |
| assigneeId | string | null | Optional | Agent ID to assign, or null to unassign. In a dashboard or app session, taking a conversation from a teammate who holds it needs the conversations.reassign permission (403 reassign_forbidden); a token is not affected |
| teamId | string | null | Optional | Team ID to assign, or null to unassign |
| muted | boolean | Optional | Mute notifications for this conversation |
| categoryId | string | null | Optional | Category id (see Tickets, Categories), or null to clear. Needs tickets switched on and in the plan; an archived category is refused |
| slaPolicyId | string | null | Optional | The conversation's own SLA policy, before its inbox's policy and the default: an active policy of the account (Enterprise), or null to take it away. Only administrators can change it (403 for an agent and for an agent's API token). A ticket is timed only by its own policy |
| customAttributes | object | Optional | Conversation attributes to set, keyed by attribute key. Merged into the stored values; null removes a key. Keys Deskwoot keeps for its own data (for example source, or the ids an import stores) are ignored and keep their value. See Custom Attributes for the value format per type. |
curl -X PATCH https://deskwoot.com/api/v1/conversations/{id} \
-H "Authorization: Bearer dw_your_token" \
-H "Content-Type: application/json" \
-d '{"status": "RESOLVED", "priority": null}'curl -X PATCH https://deskwoot.com/api/v1/conversations/{id} \
-H "Authorization: Bearer dw_your_token" \
-H "Content-Type: application/json" \
-d '{"customAttributes": {"order_id": "A-1042", "order_total": 129.9, "old_field": null}}'/v1/accounts/{accountId}/conversations/bulkUpdate multiple conversations at once. Supports status changes, assignments, priority, labels, and muting.
Auth: Bearer token (conversations:write) or session
| Parameter | Type | Required | Description |
|---|---|---|---|
| conversationIds | string[] | Required | Array of conversation IDs, at most 500 per request. The response holds affected (how many went through) and failed (the IDs a status change or snooze could not change). |
| action | string | Required | change_status, set_ticket_status, assign_agent, assign_team, change_priority, add_label, or mute. set_ticket_status takes a ticket status id, changes the tickets of the selection and lists the conversations in it under skipped |
| value | string | null | Optional | The target value for the action |
curl -X POST https://deskwoot.com/api/v1/accounts/{accountId}/conversations/bulk \
-H "Authorization: Bearer dw_your_token" \
-H "Content-Type: application/json" \
-d '{
"conversationIds": ["conv_1", "conv_2", "conv_3"],
"action": "change_status",
"value": "RESOLVED"
}'Messages
/v1/conversations/{conversationId}/messagesList all messages in a conversation, including sender info and attachments. With the Virus scan add-on, an inbound file whose scan could not run yet is listed under pendingScanAttachments (name and type, no URL) and moves to attachments once the scan has passed it.
Auth: Bearer token (conversations:read) or session
[
{
"id": "clx5mno...",
"content": "Hi, I need help with my order.",
"messageType": "INCOMING",
"private": false,
"createdAt": "2026-03-14T10:00:00Z",
"sender": { "id": "...", "name": "Lisa", "type": "contact" },
"attachments": [],
"pendingScanAttachments": []
},
{
"id": "clx6pqr...",
"content": "Sure, let me look into that for you!",
"messageType": "OUTGOING",
"private": false,
"createdAt": "2026-03-14T10:02:00Z",
"sender": { "id": "...", "name": "Jan", "type": "user" },
"attachments": []
}
]/v1/conversations/{conversationId}/historyWho did what to a conversation or ticket, newest first, under the read rules of the conversation itself. activity holds its activity lines (the team only ones too) with meta { hasMore, nextCursor, limit }. assignments holds one row per stretch of time an agent held it: assignee, assignedAt, unassignedAt (null while it lasts), startInferred, how it started (source, assignedBy) and how it ended (endSource, unassignedBy). Later releases can add source values, so treat an unknown one as a plain change. A line or stretch a merge moved in carries mergedFrom { conversationId, displayId } (displayId null when not known).
Auth: Bearer token (conversations:read) or session
| Parameter | Type | Required | Description |
|---|---|---|---|
| limit | number | Optional | Rows per page of each list (1 to 200, default 50) |
| before | string | Optional | activity.meta.nextCursor: the next page of the lines alone |
| assignmentsBefore | string | Optional | assignments.meta.nextCursor: the next page of the stretches alone (not together with before, 400 invalid_cursor) |
/v1/conversations/{conversationId}/messagesSend a new message in a conversation. The message is delivered to the customer through the original channel.
Auth: Bearer token (conversations:write) or session
| Parameter | Type | Required | Description |
|---|---|---|---|
| content | string | Required | Message text |
| private | boolean | Optional | If true, the message is only visible to agents (internal note). Default: false |
| messageType | string | Optional | OUTGOING (default) or INCOMING |
curl -X POST https://deskwoot.com/api/v1/conversations/{id}/messages \
-H "Authorization: Bearer dw_your_token" \
-H "Content-Type: application/json" \
-d '{"content": "Your order has been shipped!", "private": false}'/v1/conversations/{conversationId}/attachmentsSend a message with one or more files (multipart/form-data). All files go on one message, in the order you send them. Up to 10 files per message, 10 MB per file and 20 MB in total.
Auth: Bearer token (conversations:write) or session
| Parameter | Type | Required | Description |
|---|---|---|---|
| file | file | Required | A file to attach. Repeat the field to send several files with one message (max 10). |
| content | string | Optional | Message text sent with the files |
| private | boolean | Optional | If true, the message is only visible to agents (internal note). Default: false |
curl -X POST https://deskwoot.com/api/v1/conversations/{id}/attachments \
-H "Authorization: Bearer dw_your_token" \
-F "file=@invoice.pdf" \
-F "file=@receipt.png" \
-F "content=Here are your invoice and receipt."// 400 Bad Request
{
"error": "File too large (max 10MB)",
"code": "file_too_large",
"fileName": "receipt.png",
"fileIndex": 1
}Every file is checked before any is stored, and scanned for viruses when the workspace has the Virus scan add-on (Settings, Billing). When one file is refused, nothing is stored and no message is sent: the answer names the file with fileName and its position with fileIndex (counted from 0, in the order you sent the files). code is one of file_too_large, file_type_not_allowed, file_rejected (the virus scan blocked it), or scan_unavailable (503, the virus scan could not run, try again). A refusal of the whole set carries only error and code: too_many_files for more than 10 files, total_too_large for more than 20 MB together.
/v1/conversations/{conversationId}/labelsAdd a label to a conversation.
Auth: Bearer token (conversations:write) or session
| Parameter | Type | Required | Description |
|---|---|---|---|
| labelId | string | Required | The label ID to add |
/v1/conversations/{conversationId}/labels?labelId={labelId}Remove a label from a conversation.
Auth: Bearer token (conversations:write) or session
Inbound messages
Push customer messages from your own system (phone calls, forms, another tool) into an API inbox of the workspace. Each one is stored as an incoming customer message, like a message on any other channel: rules, assignment and notifications run.
/v1/accounts/{accountId}/inboundStore an incoming customer message. The contact is found or created by externalContactId (stored as the contact's identifier), and the message joins that contact's open conversation in the inbox or opens a new one. contactName names the contact (a new one, and an existing one whose name differs); contactEmail and contactPhone fill a contact the message creates (an email another contact has is left out). subject, priority, assigneeId and customAttributes set up a conversation the message opens (isNewConversation true); a message that joins a conversation still open leaves that conversation as it is. Answers 200 { ok, conversationId, messageId, isNewConversation }. A retry with the same sourceId, Idempotency-Key header or messageId stores nothing and answers { ok: true, deduped: true }. At most 120 requests a minute per token; 404 when inboxId is no API inbox of the workspace.
Auth: Bearer token (inbound:ingest, which the broad write scope does not cover)
| Parameter | Type | Required | Description |
|---|---|---|---|
| inboxId | string | Required | An API inbox of the workspace |
| externalContactId | string | Required | Your own id for the customer; messages with the same id share one contact |
| content | string | Required | The message |
| contentType | string | Optional | TEXT (default) or HTML |
| sourceId | string | Optional | Your own id for the message, so a retry stores nothing. Without it the Idempotency-Key header or messageId serves |
| contactName | string | Optional | The contact's name |
| contactEmail | string | Optional | The new contact's email |
| contactPhone | string | Optional | The new contact's phone number, up to 64 characters with digits |
| subject | string | Optional | The new conversation's subject, up to 1024 characters |
| priority | string | Optional | URGENT, HIGH, MEDIUM or LOW for the new conversation |
| assigneeId | string | Optional | An agent of the workspace who gets the new conversation, instead of auto assignment |
| customAttributes | object | Optional | Conversation custom attributes, checked like the conversation API checks them |
curl -X POST https://deskwoot.com/api/v1/accounts/{accountId}/inbound \
-H "Authorization: Bearer dw_your_token" \
-H "Content-Type: application/json" \
-d '{
"inboxId": "clx1api...",
"externalContactId": "crm-4711",
"sourceId": "call-2026-10-25-0012",
"content": "Call summary: the card reader does not connect.",
"contactName": "Mikey Brown",
"contactPhone": "+44 7700 900000",
"subject": "Phone call: card reader",
"priority": "HIGH",
"assigneeId": "clx9agent..."
}'To make a ticket of it, call POST /v1/conversations/{conversationId}/tickets with the conversationId you get back (see Tickets).
WhatsApp templates
WhatsApp delivers free text only within 24 hours of the customer's last message to the inbox. After that, and to start a conversation with a number that has not written in, a WhatsApp Cloud API inbox sends an approved message template. Deskwoot keeps a copy of the templates of the inbox's WhatsApp Business Account and syncs it with Meta every 6 hours, when Meta reports a change, and on Sync in the inbox settings. A free text reply after the window is refused with 422 and code: "window_closed".
/v1/conversations/{conversationId}/reply-windowWhether free text reaches the customer now, until when, and on a WhatsApp Cloud API inbox the approved templates you can send instead, to a person or a WhatsApp group (templates.available is always true).
Auth: Bearer token (conversations:read) or session
{
"channelType": "WHATSAPP",
"window": { "open": false, "openUntil": "2026-10-18T13:16:58.377Z", "hours": 24 },
"templates": {
"available": true,
"list": [
{
"id": "cmve7ios...",
"name": "order_update",
"language": "en_US",
"category": "UTILITY",
"parameterFormat": "POSITIONAL",
"body": { "text": "Hi {{1}}, your order {{2}} has shipped.", "variables": [{ "key": "1", "example": "Emma" }, { "key": "2", "example": "A-1001" }] },
"inputs": [{ "part": "body", "key": "1", "example": "Emma" }, { "part": "body", "key": "2", "example": "A-1001" }],
"headerMedia": null
}
],
"sync": { "connected": true, "syncedAt": "2026-10-18T19:16:58.223Z", "error": null }
}
}/v1/accounts/{accountId}/inboxes/{inboxId}/whatsapp-templatesEvery template of a WhatsApp Cloud API inbox as last synced, with its status. Add sendable=1 for the ones a send may use (approved, and of a kind Deskwoot sends: not authentication, carousel, flow, catalog or location templates), and contactId to learn whether that contact's window on this inbox is open.
Auth: Bearer token (inboxes:read) or session
/v1/conversations/{conversationId}/messagesSend an approved template into a conversation: pass template instead of content. The template is checked against the inbox's approved templates, each value may use the canned response variables such as {{contact.first_name}}, and the stored message holds the text the customer reads, with contentAttributes.template naming the template and its values.
Auth: Bearer token (conversations:write) or session
| Parameter | Type | Required | Description |
|---|---|---|---|
| template.id | string | Optional | The template's id from the list above. Or pass template.name and template.language. |
| template.values.header | object | string[] | Optional | Values of a text header's variable, by key ({"1": "A-1001"}) or as a list. |
| template.values.body | object | string[] | Optional | Values of the body's variables: by position ({"1": "Emma"} or ["Emma", "A-1001"]) or by name ({"first_name": "Emma"}). |
| template.values.buttons | object | Optional | By button index: the end of a URL button's link, or a copy code button's code. |
curl -X POST https://deskwoot.com/api/v1/conversations/{id}/messages \
-H "Authorization: Bearer dw_your_token" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7f9c..." \
-d '{"template": {"name": "order_update", "language": "en_US", "values": {"body": ["{{contact.first_name}}", "A-1001"]}}}'// 422 Unprocessable Entity
{
"error": "Not sent: a value of the template is missing or WhatsApp would refuse it. See problems.",
"code": "template_values_invalid",
"problems": [{ "part": "body", "key": "2", "code": "missing" }]
}A template whose header is an image, a video or a PDF goes through POST /v1/conversations/{conversationId}/attachments with the file as the one file and the template as a JSON form field template. To start a conversation with a template, pass template instead of content to POST /v1/accounts/{accountId}/conversations with contactId (a template with a file header starts no conversation); free text starts a WhatsApp conversation only when the contact wrote to the inbox in the last 24 hours, else 422 with code: "whatsapp_template_required". Refusal codes: template_not_found, template_not_approved, template_unsupported, template_values_invalid, template_media_required, template_media_type and template_not_whatsapp. WhatsApp gets the template inside the request: when it refuses it, the answer is 422 with code: "template_failed", the reason (such as paused, marketing_opt_out or per_user_limit) and the templateName, and nothing is stored. When WhatsApp reports a template as failed later, the message's status turns FAILED.
Tickets
A ticket is the team's internal work split off a customer conversation. It is a conversation with kind TICKET: it has the conversation's contact, no inbox, a title (stored as subject), an optional category, and sourceConversationId, the conversation it came from. Read and change it with the conversation endpoints (GET and PATCH /v1/conversations/{ticketId}, internal notes through its messages, labels). A ticket never writes to the customer: a public reply, a file, a draft or a macro message in a ticket answers 409 ticket_public_reply. To answer the customer, reply from the ticket with customer-reply below; the answer goes out in the conversation the ticket came from.
Lists, counts and reports leave tickets out unless you ask for them with type, so existing integrations see no change. Automation rules run on a ticket only when they have the condition Type is Ticket (see Automation Rules), and tickets have their own webhook events (see Webhooks).
/v1/conversations/{conversationId}/ticketsCreate a ticket from messages of a conversation and hand it to a team and/or an agent. The agent gets it at once, without a capacity check; a team without an agent leaves it unassigned in the team's folder, and the team's members are notified. The conversation gets a team only status card that follows the ticket. Nothing reaches the customer. An Idempotency-Key header names the request; without one the request itself does, so the same request twice within 2 minutes answers with the first ticket (200 instead of 201).
Auth: Bearer token (conversations:write) or session (conversations.manage)
| Parameter | Type | Required | Description |
|---|---|---|---|
| title | string | Required | 1 to 200 characters |
| summary | string | Optional | Up to 20,000 characters, stored as the ticket's first internal note |
| contextMessageIds | string[] | Optional | Up to 500 message ids of the conversation the ticket is made from, shown in the conversation's order |
| context | string | Optional | interaction: make it from the conversation's current interaction (what ticket-context selects by default) instead of contextMessageIds. It is read only when the request makes a new ticket, so a retry answers with the ticket it made |
| categoryId | string | Optional | A category that is not archived. Required when the workspace requires a category |
| priority | string | null | Optional | URGENT, HIGH, MEDIUM or LOW. Left out, the ticket gets the workspace's default priority for new tickets (Settings > Tickets; none unless an admin set one); null means no priority |
| teamId | string | Optional | Team that gets the ticket |
| assigneeId | string | Optional | Agent who gets the ticket: an active member of the workspace |
| resolveSource | boolean | Optional | true also resolves the conversation once the ticket is made, as a plain resolve does (its CSAT survey waits for the open ticket). Needs the right to resolve it; a retry with the same key resolves it if it is still open and its status did not change since the ticket was made |
201 or 200 { ticket }, with resolveSource { ticket, resolved } (resolved false when the conversation was resolved already, or on a retry when its status changed since the ticket was made). 400 invalid_title, invalid_summary, invalid_context (with messageIds, the ids that cannot be context), invalid_category, category_required, invalid_team, invalid_target. 403 when the plan does not include tickets. 409 tickets_disabled, ticket_source (a ticket is never made from a ticket), source_spam, category_archived, idempotency_mismatch (the same key with another body, with the ticket the key made). 500 resolve_failed with resolveSource: the ticket was made but the conversation could not be resolved; a retry with the same key resolves it.
curl -X POST https://deskwoot.com/api/v1/conversations/{conversationId}/tickets \
-H "Authorization: Bearer dw_your_token" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7f3c2a" \
-d '{
"title": "Charged twice for order 1001",
"summary": "Refund the second charge.",
"contextMessageIds": ["clx_msg1", "clx_msg2"],
"categoryId": "clx_cat_billing",
"priority": "HIGH",
"teamId": "clx_team_accounts"
}'
{
"ticket": {
"id": "clx_ticket1",
"accountId": "clx_acc1",
"displayId": 1043,
"kind": "TICKET",
"subject": "Charged twice for order 1001",
"status": "OPEN",
"priority": "HIGH",
"contactId": "clx_contact1",
"sourceConversationId": "clx_conv1",
"categoryId": "clx_cat_billing",
"createdById": "clx_user1",
"teamId": "clx_team_accounts",
"assigneeId": null,
"createdAt": "2026-10-19T09:00:00.000Z",
"lastActivityAt": "2026-10-19T09:00:00.000Z"
}
}/v1/conversations/{conversationId}/ticketsThe tickets made from a conversation, newest first (up to 100): id, displayId, title, status, priority, createdAt, lastActivityAt, assignee, team, category, createdBy, and canOpen (whether the caller may open the ticket itself).
Auth: Bearer token (conversations:read) or session
/v1/conversations/{conversationId}/ticket-contextWhat a new ticket of this conversation can be made from. defaultSelection lists the ids picked by default, oldest first: the messages since the conversation was last resolved or last made into a ticket, at least the customer's last messages; since is the moment that boundary stands at (null for the conversation's start). data and meta are one page of the messages that can be context, newest page first. 409 ticket_source for a ticket.
Auth: Bearer token (conversations:read) or session
| Parameter | Type | Required | Description |
|---|---|---|---|
| limit | number | Optional | Messages per page (1 to 200, default 50) |
| before | string | Optional | meta.nextCursor of the previous page |
/v1/conversations/{ticketId}/linked-messagesThe conversation a ticket came from, read through the ticket: whoever may read the ticket reads these, also without access to the conversation itself. source is the conversation (null once it was deleted). context holds the messages the ticket was made from, in their order, with contextMeta { hasMore, nextCursor }. newer holds the conversation's messages after the context, newest page first; it is empty, with code source_locked and the plan's reason, while the plan locks or hides the conversation. 409 not_ticket for a conversation.
Auth: Bearer token (conversations:read) or session
| Parameter | Type | Required | Description |
|---|---|---|---|
| limit | number | Optional | Newer messages per page (1 to 200, default 50) |
| before | string | Optional | newer.meta.nextCursor of the previous page |
| contextLimit | number | Optional | Context messages per page (at most and by default 500) |
| contextAfter | string | Optional | contextMeta.nextCursor: the next page of the context alone |
/v1/conversations/{ticketId}/customer-replyReply to the customer from a ticket. The reply is stored in the conversation the ticket came from and delivered there on its channel (an email in the original thread), with the limits, virus scan and Idempotency-Key replay of a normal reply. The ticket gets a team only line pointing to it. The conversation is not claimed, gets no join line and stays resolved if it was. With files, send the attachments endpoint's multipart form plus resolve=true and ticketStatusId.
Auth: Bearer token (conversations:write) or session (conversations.manage)
| Parameter | Type | Required | Description |
|---|---|---|---|
| content | string | Required | The reply |
| contentType | string | Optional | TEXT or HTML |
| subject | string | Optional | Email subject |
| cc | string[] | Optional | Email cc addresses |
| bcc | string[] | Optional | Email bcc addresses |
| resolve | boolean | Optional | Also resolve the ticket (never the conversation) |
| ticketStatusId | string | Optional | With resolve: a ticket status under Resolved to resolve the ticket into |
200 { message, reference, resolved }: the reply in the conversation, the ticket's line for it, and whether this request resolved the ticket. A draft only (supervised) token is refused with 403, and an inbox restricted token needs the conversation's inbox. 409 not_ticket, tracker_no_customer (a tracker has no customer), source_deleted, source_spam, contact_blocked, source_no_inbox; 403 source_locked while the plan locks the conversation.
/v1/conversations/{conversationId}/resolve-as-ticketResolve a conversation and file its current interaction as a resolved ticket (Keep as a resolved ticket in the Resolve chat dialog). The interaction is what ticket-context selects by default. The caller creates and holds the ticket, it gets the conversation's team, the category asked for or else the conversation's own, the conversation's priority or else the workspace's default priority for new tickets, and a title from the interaction. It begins at the interaction's first customer message, so its resolution time is the interaction's. Nobody is notified. The ticket is filed first: a refusal leaves the conversation as it was. When nothing was written since the last resolve or ticket, ticket is null and the conversation is still resolved. An Idempotency-Key makes a retry answer with the same ticket.
Auth: Bearer token (conversations:write) or session (conversations.manage)
| Parameter | Type | Required | Description |
|---|---|---|---|
| categoryId | string | null | Optional | The ticket's category |
201 or 200 { ticket, resolved }. 400 category_required, invalid_category. 409 tickets_disabled, ticket_source, source_spam, category_archived, idempotency_mismatch. 500 resolve_failed (with the ticket that was filed) when the resolve fails after the filing.
/v1/conversations/{conversationId}/ticket-draftDraft with Copilot: a title and a summary for a ticket made from these messages of the conversation, and from nothing else. Nothing is written and nothing reaches the customer. Needs the Copilot (its plan, quota and rate limits) and the conversations.notes_summarize permission; billed like a Copilot answer. Answers { title, summary, usedCount, omittedCount }.
Auth: Session only
| Parameter | Type | Required | Description |
|---|---|---|---|
| messageIds | string[] | Required | 1 to 500 message ids of the conversation |
| locale | string | Optional | The language to write in; the dashboard's language wins |
Changing a ticket
PATCH /v1/conversations/{ticketId} takes the conversation fields (status, priority, assigneeId, teamId, customAttributes) plus title (1 to 200 characters), summary (1 to 20,000 characters, replaces the summary note), categoryId (null clears it) and ticketStatusId (a custom ticket status: the ticket takes its base status, and a status that contradicts it answers 400 ticket_status_mismatch; null keeps the base status without a custom one). An assignee gets the ticket at once, without a capacity check. A ticket is refused an inbox (409 ticket_inbox) and the spam status (409 ticket_spam); title and summary on a conversation answer 400 not_ticket.
Categories
Categories sort conversations and tickets. A category name exists once per workspace, whatever the letter case. An archived category stays on the conversations and tickets that have it but is not offered for new ones.
/v1/accounts/{accountId}/ticket-categoriesThe workspace's categories in their order, archived ones included: id, name, position, archived, archivedAt.
Auth: Bearer token (conversations:read) or session
/v1/accounts/{accountId}/ticket-categoriesAdd a category at the end of the order. 409 name_taken when the name exists already.
Auth: Session (permission: settings.manage_ticket_categories), Enterprise plan
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Required | 1 to 80 characters |
/v1/accounts/{accountId}/ticket-categories/{categoryId}Rename, move or archive a category.
Auth: Session (permission: settings.manage_ticket_categories), Enterprise plan
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Optional | 1 to 80 characters |
| position | number | Optional | The index in the order (archived ones included) it moves to |
| archived | boolean | Optional | Archive or unarchive |
/v1/accounts/{accountId}/ticket-categories/{categoryId}Delete a category. 409 category_in_use while a conversation or ticket has it; archive it instead.
Auth: Session (permission: settings.manage_ticket_categories), Enterprise plan
Ticket statuses
A workspace's own statuses for tickets, each under a base status (OPEN, PENDING or RESOLVED) that a ticket in it has. One under OPEN may pause the SLA. A ticket carries its custom status as ticketStatus (id, name, baseStatus, pausesSla), null in a plain base status. A base status change without a custom status drops it. Rules and macros set one with the action set_ticket_status and test it with the condition ticket_status_id.
/v1/accounts/{accountId}/ticket-statusesThe workspace's ticket statuses in their order, archived ones included: id, name, baseStatus, pausesSla, position, archived, archivedAt.
Auth: Bearer token (conversations:read) or session
/v1/accounts/{accountId}/ticket-statusesAdd a status at the end of the order, at most 50 per workspace. 409 name_taken when the name exists already.
Auth: Session (permission: settings.manage_ticket_categories), Enterprise plan
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Required | 1 to 40 characters |
| baseStatus | string | Required | OPEN, PENDING or RESOLVED |
| pausesSla | boolean | Optional | Only under OPEN |
/v1/accounts/{accountId}/ticket-statuses/{statusId}Rename, move or archive a status, or switch its SLA pause (from that moment on).
Auth: Session (permission: settings.manage_ticket_categories), Enterprise plan
/v1/accounts/{accountId}/ticket-statuses/{statusId}Delete a status. 409 ticket_status_in_use while a ticket has it; archive it instead.
Auth: Session (permission: settings.manage_ticket_categories), Enterprise plan
Trackers
A tracker is one problem that many customers have, like an outage or a bug. It is a ticket without a customer: contactId, sourceConversationIdand the inbox are null, and customers' conversations and tickets link to it. Read and change the tracker itself like any ticket. Every member sees every tracker, except an agent whose role sees only their own work. Creating a tracker and linking need tickets switched on and the Enterprise plan; unlinking and reading keep working after a downgrade. A tracker counts in no inbox, so an inbox restricted token creates none and changes no tracker's links. A tracker merges only with a tracker (409 tracker_merge).
/v1/accounts/{accountId}/trackersCreate a tracker, open, with its summary as the first internal note. An Idempotency-Key header makes a repeat answer with the first tracker (replayed: true) instead of a second one.
Auth: Bearer token (conversations:write) or session (conversations.manage)
| Parameter | Type | Required | Description |
|---|---|---|---|
| title | string | Required | 1 to 200 characters |
| summary | string | Optional | Up to 20,000 characters, stored as the first internal note |
| categoryId | string | Optional | Required when the workspace requires a category; not archived |
| priority | string | null | Optional | URGENT, HIGH, MEDIUM or LOW. Left out, the workspace's default priority for new tickets; null means none |
| teamId | string | Optional | Team that gets the tracker |
| assigneeId | string | Optional | Agent who gets the tracker: an active member of the workspace |
201 { ticket }. 400 invalid_title, invalid_summary, invalid_category, category_required, invalid_team, invalid_target, invalid_idempotency_key. 403 when the plan does not include tickets or the token is limited to some inboxes. 409 tickets_disabled, category_archived, idempotency_in_progress.
/v1/conversations/{trackerId}/tracker-linksWhat is linked to a tracker, newest link first: id, displayId, kind, title, status, priority, sourceConversationId, contact (with its organization), channelType, assignee, team, linkedAt, linkedBy, and lastUpdate, what the tracker's latest update did for it (null when it did not name it). meta { hasMore, nextCursor, limit, hiddenCount, shownCount }: hiddenCount counts the linked items you may not open, which are left out. On a conversation or a customer's ticket it answers { trackers } instead: the trackers it is linked to that you may open (at most 100).
Auth: Bearer token (conversations:read) or session
| Parameter | Type | Required | Description |
|---|---|---|---|
| limit | number | Optional | Rows per page (1 to 100, default 100) |
| before | string | Optional | meta.nextCursor of the previous page (400 when it is no cursor) |
/v1/conversations/{trackerId}/tracker-linksLink conversations and customers' tickets to a tracker, with a line on both. Answers { results }, one per id in the order asked: { conversationId, outcome } with outcome linked, already_linked or refused (with code not_found, tracker or source_spam, and error). A row you may not open is not_found. The tracker sends ticket.updated with changes.links.
Auth: Bearer token (conversations:write) or session (conversations.manage)
| Parameter | Type | Required | Description |
|---|---|---|---|
| conversationIds | string[] | Required | 1 to 100 ids of conversations and tickets |
409 not_tracker, tickets_disabled; 403 when the plan does not include tickets or the token is limited to some inboxes.
/v1/conversations/{trackerId}/tracker-links/{conversationId}Unlink one conversation or ticket from a tracker, with a line on both. A conversation whose survey waited for the tracker gets it now, unless something else holds it. Answers { unlinked: true }; 409 not_tracker, not_linked.
Auth: Bearer token (conversations:write) or session (conversations.manage)
/v1/conversations/{trackerId}/linked-updateUpdate the customers of everything linked to a tracker, and resolve the linked items and the tracker, as asked. Text only. Each conversation gets the update once (a conversation and its tickets count as one, at most 500), as a reply in that conversation with its reply window and refusals. dryRun answers what would happen and writes nothing. Every other request needs an Idempotency-Key header: a retry sends nothing again and answers replayed for what the first one sent. A person sends at most 10 updates a minute.
Auth: Session (conversations.manage) or a REST API token (conversations:write); MCP tokens are refused
| Parameter | Type | Required | Description |
|---|---|---|---|
| content | string | Optional | The update |
| contentType | string | Optional | TEXT or HTML |
| subject | string | Optional | Email subject |
| resolveLinked | boolean | Optional | Also resolve the linked conversations and tickets |
| resolveTracker | boolean | Optional | Also resolve the tracker, last |
| dryRun | boolean | Optional | Answer what would happen; nothing is written or sent |
200 { recipients, resolved, hiddenCount, trackerResolved, unfinished, dryRun }: one recipient per linked item you may open, with outcome sent, replayed, ready (dry run), same_chat, refused (with a code) or failed. unfinished is true when a send broke off before its message was stored; a retry with the same key finishes it. 400 nothing_to_do, idempotency_key_required. 403 mcp_not_allowed for an MCP token. 409 not_tracker, idempotency_mismatch. 413 too_many_recipients. 429 rate_limited.
Linked Jira issues
With Jira connected under Settings > Integrations > Jira, a ticket (a tracker too) links to issues in Jira, and an issue to many tickets. Each linked issue keeps its last known status from Jira, which Jira's webhooks and an hourly check keep current, and each link, unlink and new status writes a line in the ticket and sends its ticket.updated(see Webhooks). Reading follows the ticket's read rules and works without the plan or a connection. Linking and unlinking follow the rules of editing the ticket and need tickets switched on, the Enterprise plan and a working Jira connection; without them the ticket's issues stay readable. An inbox restricted token links and unlinks on tickets whose conversation is in its inboxes. Raising a new issue happens in the dashboard only: it is created under the member's own Atlassian account, which an API token does not have, so the raise endpoint answers a token 403 person_only.
/v1/conversations/{ticketId}/linked-issuesA ticket's linked issues, the first linked first: { data: [{ id, provider, siteId, externalId, key, url, title, statusName, statusCategory, deleted, notFound, syncedAt, raised, linkedAt, linkedBy }], jira }. statusCategory is TODO, IN_PROGRESS or DONE; syncedAt is when Jira last reported that state; deleted is true once the issue was deleted in Jira, notFound when the hourly check no longer finds it. jira is { state, siteId } of the workspace's connection, or null without one; an issue of another site, or any issue while the connection needs a new sign in, shows its last known state. 401 without a valid token or session. 403 when the token lacks conversations:read, the caller may not read the ticket, or the plan's monthly cap locks it. 404 when the ticket is not found or is older than the plan's history window. 409 not_a_ticket.
Auth: Bearer token (conversations:read) or session
/v1/conversations/{ticketId}/linked-issuesLink an existing Jira issue by its key. Deskwoot reads the issue with the workspace's Jira connection, links it once and then writes a link back to the ticket in Jira. 201 { issue } for a new link, 200 { issue } when it was linked already.
Auth: Bearer token (conversations:write) or session (conversations.manage)
| Parameter | Type | Required | Description |
|---|---|---|---|
| key | string | Required | The issue key, such as TAB-12, in any case |
400 invalid_issue_key. 403 when the plan does not include tickets, or for a token limited to inboxes the ticket's conversation is not in. 404 jira_not_found when Jira does not know the issue or the connected account may not see it. 409 not_a_ticket, tickets_disabled, jira_not_connected, jira_reauth_required, jira_forbidden. 502 jira_unavailable.
/v1/conversations/{ticketId}/linked-issues/{issueId}Unlink an issue (its id from the GET above) and remove the link back to the ticket in Jira. The issue stays in Jira. Answers { success: true }; 404 not_linked; 403 and 409 tickets_disabled as for linking; 409 jira_not_connected or jira_reauth_required, so Jira and Deskwoot do not disagree about the link.
Auth: Bearer token (conversations:write) or session (conversations.manage)
MCP tools
The MCP server offers tickets to an AI agent with the same rules as these endpoints. The ticket tools take a ticket's id and refuse a conversation's. A ticket counts in the inbox of the conversation it came from, on the endpoints and in the tools alike: an inbox restricted token reads tickets, and creates, comments on, updates and replies from tickets within its inboxes. A ticket whose conversation is gone counts in no inbox. Creating a ticket or a tracker, linking to a tracker and linking or unlinking a Jira issue need the Enterprise plan; the other tools keep working after a downgrade.
| Tool | What it does |
|---|---|
| tickets_list | Lists tickets, filtered by status, custom ticket status, category, priority, assignee, team, contact, custom attributes, a linked Jira issue's key or search (read) |
| tickets_get | One ticket with its thread, its linked conversation context and its linked Jira issues (read) |
| ticket_categories_list | The categories (read) |
| ticket_statuses_list | The custom ticket statuses (read) |
| tickets_create | A ticket from a conversation's messages, the current interaction when none are named, with the workspace's default priority unless one is given (null for none) (internal) |
| tickets_add_comment | An internal comment on a ticket (internal) |
| tickets_update | Status, custom ticket status, assignee, team, category, priority or title (internal) |
| tickets_reply_to_customer | A reply to the customer in the conversation the ticket came from, optionally resolving the ticket. Refused for a draft only token, like conversations_reply |
| trackers_create | A tracker, with the workspace's default priority unless one is given (null for none) (internal) |
| trackers_link | Links 1 to 100 conversations or tickets to a tracker, one result per id (internal) |
| trackers_unlink | Unlinks one conversation or ticket from a tracker (internal) |
| trackers_linked_list | What is linked to a tracker, with each customer and the tracker's last update for it (read) |
| tickets_link_issue | Links an existing Jira issue to a ticket by its key (internal) |
| tickets_unlink_issue | Unlinks an issue from a ticket by its id from tickets_get (internal) |
conversations_list takes type, category_id, priority and attributes (custom attributes that must match) too, and events_poll carries the ticket events. tickets_list takes tracker (only or exclude) and issue_key. No tool sends a tracker's update to its customers, and no tool raises a new Jira issue: a person does both in the dashboard.
Contacts
/v1/accounts/{accountId}/contactsList contacts with optional search and segment filtering. Supports pagination.
Auth: Bearer token (contacts:read) or session
| Parameter | Type | Required | Description |
|---|---|---|---|
| identifier | string | Optional | Exact-match lookup by the contact's unique identifier: the user id your site signs for Identity Verification, else the contact's own identifier. Returns at most one contact and ignores search, segment, and pagination. |
| telegramId | string | Optional | Exact-match lookup by Telegram chat id: a person's user id or a group's negative chat id. Returns at most one contact and ignores search, segment, and pagination. |
| search | string | Optional | Search by name, email, or phone number. A phone number matches however it is written: 07700 900000 finds +44 7700 900000 and the other way round. |
| segment | string | Optional | Filter: active-7d, active-30d, no-email, has-email, has-phone, new |
| page | number | Optional | Page number (default: 1) |
| pageSize | number | Optional | Items per page (default: 25) |
{
"contacts": [
{
"id": "clx7stu...",
"name": "Lisa Schmidt",
"email": "lisa@example.com",
"phone": "+49 170 1234567",
"avatarUrl": null,
"company": "Acme GmbH",
"lastActivityAt": "2026-03-14T09:00:00Z",
"createdAt": "2026-01-15T12:00:00Z",
"organization": { "id": "clx3org...", "name": "Acme GmbH" },
"verified": true,
"verifiedIdentifier": "user_4711"
}
],
"total": 142,
"page": 1,
"pageSize": 25
}
// verified: the contact signed in through Identity Verification.
// verifiedIdentifier: the user id your site signed for it (null when not
// verified). Only API token requests receive it./v1/accounts/{accountId}/contacts/{contactId}Get one contact with its notes, addresses and latest conversations. Like the list, it carries verified, and for an API token verifiedIdentifier (the user id your site signed for Identity Verification). The identifier field is the contact's own identifier, set when the contact was created.
Auth: Bearer token (contacts:read) or session
/v1/accounts/{accountId}/contactsCreate a contact. Needs a name or an email. An email another contact has answers 409 with its contactId; an identifier or telegramId another contact has answers 409 with code identifier_taken or telegram_id_taken and that contact's id. Answers 201 with the contact.
Auth: Bearer token (contacts:write) or session (permission: contacts.manage)
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Optional | The contact's name |
| string | Optional | Unique in the workspace | |
| phone | string | Optional | A phone number with digits |
| company | string | Optional | Free text |
| identifier | string | Optional | Your own id for the contact, unique in the workspace; find it again with GET ?identifier= |
| organizationId | string | Optional | An organization of the workspace (see Organizations) |
| telegramId | string | Optional | The Telegram chat the contact is: a person's user id, or a group's negative chat id (the contact then counts as a group chat). Set before the chat's first message, that message joins this contact |
| customAttributes | object | Optional | Contact custom attributes |
/v1/accounts/{accountId}/contacts/{contactId}Change the fields you send (the fields of the create except identifier, plus tags, locale and blocked); null clears one, and custom attributes merge (null removes a key). telegramId can be set only while the contact has none (400 with reason locked otherwise); an email or telegramId another contact has answers 409 with code email_taken or telegram_id_taken.
Auth: Bearer token (contacts:write) or session (permission: contacts.manage)
/v1/accounts/{accountId}/contacts/mergeMerge two contact records into one. The primary contact keeps its data; the secondary contact's conversations are moved over.
Auth: Bearer token (contacts:write) or session
| Parameter | Type | Required | Description |
|---|---|---|---|
| primaryContactId | string | Required | The contact to keep |
| secondaryContactId | string | Required | The contact to merge and delete |
Organizations
An organization is a company your contacts belong to. A contact belongs to one organization at most: send organizationId (or null to remove it) when you create or update a contact. An id from another workspace is refused with 400 and field: "organizationId".
/v1/accounts/{accountId}/organizationsList organizations from A to Z, each with the number of its contacts. With search, organizations named exactly as searched (apart from case) come first. Supports pagination.
Auth: Bearer token (contacts:read) or session
| Parameter | Type | Required | Description |
|---|---|---|---|
| search | string | Optional | Matches the name or the domain |
| page | number | Optional | Page number (default: 1) |
| pageSize | number | Optional | Items per page (default: 25, at most 100) |
{
"organizations": [
{
"id": "clx3org...",
"name": "Acme GmbH",
"domain": "acme.com",
"industry": "Retail",
"website": "https://acme.com",
"phone": "+49 30 1234567",
"createdAt": "2026-01-15T12:00:00Z",
"updatedAt": "2026-03-14T09:00:00Z",
"_count": { "contacts": 12 }
}
],
"total": 1,
"page": 1,
"pageSize": 25
}/v1/accounts/{accountId}/organizationsCreate an organization. A name or domain another organization of the workspace already has (apart from case) answers 409 with code name_taken or domain_taken and that organization's id.
Auth: Bearer token (contacts:write) or session (permission: contacts.manage)
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Required | Unique in the workspace, at most 255 characters |
| domain | string | Optional | A domain such as acme.com, without https:// or a path; stored in lower case |
| website | string | Optional | An http or https link, or a bare host such as acme.com |
| phone | string | Optional | A phone number with digits |
| industry | string | Optional | Free text |
| description | string | Optional | Free text |
| notes | string | Optional | Internal notes for your team |
/v1/accounts/{accountId}/organizations/{organizationId}Get one organization with a page of its contacts (50 per page, A to Z, each with the number of its conversations) and its 20 most recent conversations, plus _count.contacts and _count.conversations. Pass contactsPage for the next page of contacts, and contactsOnly=true to leave the conversations and _count.conversations out. A token without conversations:read gets no conversations and no _count.conversations, on the organization or on its contacts.
Auth: Bearer token (contacts:read) or session
/v1/accounts/{accountId}/organizations/{organizationId}Change the fields you send (the same fields as the create); null clears one, the name cannot be cleared. Answers 409 like the create.
Auth: Bearer token (contacts:write) or session (permission: contacts.manage)
/v1/accounts/{accountId}/organizations/{organizationId}Delete an organization. Its contacts and their conversations stay; they no longer belong to an organization.
Auth: Session (permission: contacts.delete)
/v1/accounts/{accountId}/contacts/{contactId}/group-organizationMake a Telegram, LINE or WhatsApp group chat (the contact is the group) an organization. The group joins it. Teammates, recognized by the Telegram they connected under Settings > Notifications, are left out. Anyone else who wrote in the group joins when they are a contact of their own (on LINE and WhatsApp: people who also chat with you directly) and have no organization yet, also a contact a group added before and an agent took out; blocked contacts stay out. Send name to create a new organization, or organizationId to add the group to an existing one. Answers the organization, created and linked (how many contacts joined, the group among them), with 201 for a new organization. A name another organization has answers 409 with code name_taken, that organization's id and its name; a group that already has an organization answers 409 with code has_organization; a contact that is no group chat answers 400 with code not_a_group.
Auth: Bearer token (contacts:write) or session (permission: contacts.manage)
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Optional | The new organization's name, unique in the workspace |
| organizationId | string | Optional | An organization of the workspace, instead of name |
{
"organization": { "id": "clx3org...", "name": "Acme GmbH" },
"created": true,
"linked": 4
}Inboxes
/v1/accounts/{accountId}/inboxesList all inboxes in your account.
Auth: Bearer token (read) or session
[
{
"id": "clx8uvw...",
"name": "Website Chat",
"channelType": "WEB",
"greeting": "Hi! How can we help?",
"greetingEnabled": true,
"enableAutoAssign": true,
"csatEnabled": true,
"csatRatingStyle": "STARS",
"csatPromptText": null,
"createdAt": "2026-01-01T00:00:00Z"
},
{
"id": "clx9xyz...",
"name": "Support Email",
"channelType": "EMAIL",
"emailAddress": "support@acme.com",
"createdAt": "2026-01-01T00:00:00Z"
}
]/v1/accounts/{accountId}/inboxesCreate a new inbox. Supported channel types: WEB, EMAIL, TELEGRAM, WHATSAPP, SMS, LINE, TWITTER.
Auth: Session (permission: settings.manage_inboxes)
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Required | Display name for the inbox |
| channelType | string | Required | WEB, EMAIL, TELEGRAM, WHATSAPP, SMS, LINE, or TWITTER |
| greeting | string | Optional | Welcome message shown to visitors |
| greetingEnabled | boolean | Optional | Show greeting message (default: false) |
| enableAutoAssign | boolean | Optional | Auto-assign to available agents (default: true) |
| botEnabled | boolean | Optional | Enable AI bot for this inbox |
| agentBotId | string | Optional | Bot ID to use when botEnabled is true |
curl -X POST https://deskwoot.com/api/v1/accounts/{accountId}/inboxes \
-H "Authorization: Bearer dw_your_token" \
-H "Content-Type: application/json" \
-d '{
"name": "Telegram Support",
"channelType": "TELEGRAM",
"telegramBotToken": "123456:ABC-DEF..."
}'/v1/accounts/{accountId}/inboxes/{inboxId}Get full details for a single inbox.
Auth: Bearer token (read) or session
/v1/accounts/{accountId}/inboxes/{inboxId}Update inbox settings such as name, greeting, auto-assign mode, CSAT, business hours, widget color, and bot configuration.
Auth: Session (permission: settings.manage_inboxes)
{
"csatEnabled": true,
"csatRatingStyle": "STARS", // STARS | HEARTS | FIRE | SMILEYS
"csatPromptText": "How was your experience today?", // null = default
"csatEmailFallbackInboxId": "clx9xyz...", // an EMAIL inbox of the account; null = off
"csatInGroups": false // TELEGRAM only: also survey group chats
}csatRatingStyle is validated against the four allowed values. csatPromptText is capped at 240 characters and empty string is normalized to null (fall back to the localized default). csatEmailFallbackInboxId names the email inbox that mails the survey when the chat cannot show it: it must be an EMAIL inbox of the same account with an address to send from, and an EMAIL inbox itself takes only null. csatInGroupssends the survey into a Telegram inbox's group chats, where each member who wrote can rate once; only a TELEGRAM inbox can switch it on.
/v1/accounts/{accountId}/inboxes/{inboxId}Delete an inbox and all associated data. This action cannot be undone.
Auth: Session (permission: settings.manage_inboxes)
Agents
/v1/accounts/{accountId}/agentsList all agents (team members) in your account, including their role and custom role.
Auth: Bearer token (read) or session
{
"agents": [
{
"user": {
"id": "clx_user1",
"name": "Anna",
"email": "anna@example.com",
"avatarUrl": null
},
"role": "ADMINISTRATOR",
"customRole": null
}
],
"maxAgents": 10,
"plan": "ENTERPRISE"
}/v1/accounts/{accountId}/agentsInvite a new agent to your account. An invitation email is sent automatically.
Auth: Session (admin only)
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Required | Full name of the new agent |
| string | Required | Email address | |
| role | string | Optional | AGENT (default) or ADMINISTRATOR |
| customRoleId | string | Optional | ID of a custom role to assign |
/v1/accounts/{accountId}/agents/{agentId}Update an agent's role.
Auth: Session (admin only)
| Parameter | Type | Required | Description |
|---|---|---|---|
| role | string | Optional | AGENT or ADMINISTRATOR |
| customRoleId | string | null | Optional | Custom role ID or null to remove |
/v1/accounts/{accountId}/agents/{agentId}Remove an agent from your account.
Auth: Session (admin only)
Teams
/v1/accounts/{accountId}/teamsList all teams with their member count.
Auth: Bearer token (read) or session
/v1/accounts/{accountId}/teamsCreate a new team.
Auth: Session (permission: settings.manage_teams)
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Required | Team name |
| description | string | Optional | Team description |
| allowAutoAssign | boolean | Optional | Allow auto-assignment to this team (default: true) |
/v1/accounts/{accountId}/teams/{teamId}Get a team with its full member list.
Auth: Bearer token (read) or session
/v1/accounts/{accountId}/teams/{teamId}Update team name, description, or auto-assign setting.
Auth: Session (permission: settings.manage_teams)
/v1/accounts/{accountId}/teams/{teamId}Delete a team.
Auth: Session (permission: settings.manage_teams)
/v1/accounts/{accountId}/teams/{teamId}/membersAdd an agent to a team.
Auth: Session (permission: settings.manage_teams)
| Parameter | Type | Required | Description |
|---|---|---|---|
| userId | string | Required | The agent's user ID |
/v1/accounts/{accountId}/teams/{teamId}/membersRemove an agent from a team.
Auth: Session (permission: settings.manage_teams)
| Parameter | Type | Required | Description |
|---|---|---|---|
| userId | string | Required | The agent's user ID |
Labels
/v1/accounts/{accountId}/labelsList all labels in your account.
Auth: Bearer token (read) or session
[
{
"id": "clx_label1",
"title": "VIP",
"color": "#6366F1",
"description": "High-value customers",
"showOnSidebar": true
}
]/v1/accounts/{accountId}/labelsCreate a new label.
Auth: Session (permission: settings.manage_labels)
| Parameter | Type | Required | Description |
|---|---|---|---|
| title | string | Required | Label name |
| color | string | Optional | Hex color code (default: "#6366F1") |
| description | string | Optional | Label description |
| showOnSidebar | boolean | Optional | Show in sidebar filter (default: true) |
/v1/accounts/{accountId}/labels/{labelId}Update a label's title, color, description, or visibility.
Auth: Session (permission: settings.manage_labels)
/v1/accounts/{accountId}/labels/{labelId}Delete a label. It will be removed from all conversations.
Auth: Session (permission: settings.manage_labels)
Canned Responses
/v1/accounts/{accountId}/canned-responsesList all canned responses.
Auth: Bearer token (read) or session
/v1/accounts/{accountId}/canned-responsesCreate a canned response. Agents can use it by typing the short code in the message input.
Auth: Session (permission: settings.manage_canned_responses)
| Parameter | Type | Required | Description |
|---|---|---|---|
| shortCode | string | Required | Trigger shortcode (e.g. 'greeting') |
| content | string | Required | Response text. Supports variables: {{agent.name}}, {{agent.first_name}}, {{agent.last_name}}, {{agent.email}}, {{contact.name}}, {{contact.first_name}}, {{contact.last_name}}, {{contact.email}}, {{contact.phone}}, {{contact.company}}, {{conversation.id}}, {{inbox.name}}, {{account.name}}. Optional fallback: {{contact.first_name | there}}. Unknown variables are left as written. |
/v1/accounts/{accountId}/canned-responses/{cannedResponseId}/renderExpand the variables of a canned response for one conversation and return the text ready to send. The agent variables resolve to the calling user (for API tokens: the token owner). Returns { content }.
Auth: Bearer token (scope: conversations:read) or session
| Parameter | Type | Required | Description |
|---|---|---|---|
| conversationId | string | Required | Conversation whose contact, inbox and account fill the variables |
/v1/accounts/{accountId}/canned-responses/{cannedResponseId}Update a canned response.
Auth: Session (permission: settings.manage_canned_responses)
/v1/accounts/{accountId}/canned-responses/{cannedResponseId}Delete a canned response.
Auth: Session (permission: settings.manage_canned_responses)
Macros
/v1/accounts/{accountId}/macrosList all public macros and your personal macros.
Auth: Bearer token (read) or session
{
"data": [
{
"id": "clx_macro1",
"name": "Close and resolve",
"visibility": "PUBLIC",
"actions": [
{ "type": "change_status", "value": "RESOLVED" },
{ "type": "send_message", "value": "Thanks for reaching out! Closing this now." }
]
}
]
}/v1/accounts/{accountId}/macrosCreate a new macro with one or more actions.
Auth: Bearer token (write) or session
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Required | Macro name |
| actions | array | Optional | Array of action objects with type and value |
| visibility | string | Optional | PERSONAL (default) or PUBLIC |
/v1/accounts/{accountId}/macros/{macroId}Update a macro.
Auth: Bearer token (write) or session
/v1/accounts/{accountId}/macros/{macroId}Delete a macro.
Auth: Bearer token (write) or session
Automation Rules
/v1/accounts/{accountId}/automation-rulesList all automation rules.
Auth: Bearer token (read) or session
/v1/accounts/{accountId}/automation-rulesCreate an automation rule with conditions and actions.
Auth: Session (permission: settings.manage_automation)
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Required | Rule name |
| eventName | string | Required | Trigger event: CONVERSATION_CREATED, CONVERSATION_UPDATED, MESSAGE_CREATED, TIME_ELAPSED (a time rule, see below) or LINKED_ISSUE_UPDATED (a ticket's linked Jira issue changed, see below) |
| conditions | array | Optional | Array of condition objects (attribute, operator, value). All of them must match. With tickets: conversation_type (conversation or ticket), category_id (a category id) and ticket_status_id (a ticket status id), both with operator equal_to or not_equal_to |
| actions | array | Optional | Array of action objects (type, value) |
| active | boolean | Optional | Enable the rule (default: true) |
Tickets: a rule runs on a ticket only when it has a conversation_type condition, such as Type is Ticket; every other rule runs on conversations alone. On a ticket, CONVERSATION_CREATED fires when a ticket is created or filed, CONVERSATION_UPDATED when it changes, and MESSAGE_CREATED for each internal comment. A ticket never writes to the customer: a rule that can run on tickets and has send_message or send_email is refused with 400, and such an action is skipped on a ticket. Assign Agent and Assign Team hand a ticket over at once, without a capacity check. The action set_ticket_status (a ticket status id) is for rules on tickets only; it skips a conversation, and a rule that has it without an id is refused with 400. The action apply_sla_policy(an SLA policy id, or none to remove the conversation's own policy) is for administrators: anyone else gets 403 sla_policy_admin_only when saving such a rule or changing one that has it.
Time rules: a rule with the event TIME_ELAPSED needs exactly one timer condition, one of hours_since_last_activity, hours_since_status_change or hours_since_created, with operator is_greater_than and a whole number of hours from 1 to 8,760 as its value. Such a rule cannot check message_type or content, and no other rule can have a timer; both are refused with 400. Time rules are checked every 5 minutes on open and pending conversations, and a rule acts on a conversation once until the time it measures starts over.
Linked issue rules: a rule with the event LINKED_ISSUE_UPDATED runs once per ticket each time a Jira issue is raised from it, linked to it, unlinked from it, or changes its status in Jira, and never on a resolved ticket. It needs the condition Type is Ticket and cannot check message_type or content. Its own conditions, which no other rule can have: linked_issue_change (raised, linked, status_changed or unlinked; several values in values), linked_issue_status_category (todo, in_progress or done), linked_issue_previous_status_category (the category before a status_changed, none on any other change) and linked_issue_status (the Jira status name in any case, with equal_to, not_equal_to or contains). The action assign_creator with { "to": "issue_linker" } hands the ticket to the person who linked the issue, on these rules only. These are refused with 400: a linked issue condition or issue_linker on any other event, and a linked issue rule that lacks Type is Ticket or checks message_type or content.
curl -X POST https://deskwoot.com/api/v1/accounts/{accountId}/automation-rules \
-H "Authorization: Bearer dw_your_token" \
-H "Content-Type: application/json" \
-d '{
"name": "Auto-assign VIP to senior team",
"eventName": "CONVERSATION_CREATED",
"conditions": [
{ "attribute": "label", "value": "VIP" }
],
"actions": [
{ "actionName": "assign_team", "actionParams": "senior-support-team-id" }
]
}'{
"name": "Billing tickets to Accounts",
"eventName": "CONVERSATION_CREATED",
"conditions": [
{ "attribute": "conversation_type", "value": "ticket" },
{ "attribute": "category_id", "operator": "equal_to", "value": "clx_cat_billing" }
],
"actions": [
{ "actionName": "assign_team", "actionParams": "clx_team_accounts" }
]
}/v1/accounts/{accountId}/automation-rules/{ruleId}Update an automation rule.
Auth: Session (permission: settings.manage_automation)
/v1/accounts/{accountId}/automation-rules/{ruleId}Delete an automation rule.
Auth: Session (permission: settings.manage_automation)
Campaigns
/v1/accounts/{accountId}/campaignsList all campaigns (ongoing and one-off).
Auth: Bearer token (read) or session
/v1/accounts/{accountId}/campaignsCreate a new campaign.
Auth: Session (permission: settings.manage_campaigns)
| Parameter | Type | Required | Description |
|---|---|---|---|
| title | string | Required | Campaign title |
| message | string | Required | Message content |
| campaignType | string | Optional | ONGOING or ONE_OFF (default: ONGOING) |
| inboxId | string | Optional | Inbox to send from |
| scheduledAt | string | Optional | ISO 8601 date and time with Z or an offset, for example 2026-10-02T14:30:00Z, for one-off campaigns |
| audience | object | Optional | Audience targeting rules |
/v1/accounts/{accountId}/campaigns/{campaignId}Get campaign details.
Auth: Bearer token (read) or session
/v1/accounts/{accountId}/campaigns/{campaignId}Update a campaign.
Auth: Session (permission: settings.manage_campaigns)
/v1/accounts/{accountId}/campaigns/{campaignId}/sendSend a one-off campaign immediately.
Auth: Session (permission: settings.manage_campaigns)
/v1/accounts/{accountId}/campaigns/{campaignId}Delete a campaign.
Auth: Session (permission: settings.manage_campaigns)
Webhooks
Webhooks send you an HTTP notification the moment events happen in your Deskwoot account. Every webhook payload is signed with HMAC-SHA256 so you can verify authenticity.
Delivery format
Every event is delivered as an HTTP POST with a JSON body. The body wraps the event data in a fixed envelope, and two headers identify the event and carry the signature.
| Field | Type | Description |
|---|---|---|
| X-Deskwoot-Event | header | The event name, for example message.created |
| X-Deskwoot-Signature | header | sha256=<hex HMAC of the raw body>, signed with the webhook secret |
| event | string | Event name, same value as the X-Deskwoot-Event header |
| data | object | Event specific payload, see the event list below |
| timestamp | string | ISO 8601 time the event was dispatched |
| accountId | string | The account the event belongs to |
POST https://your-app.com/webhooks/deskwoot
Content-Type: application/json
X-Deskwoot-Event: message.created
X-Deskwoot-Signature: sha256=5f1a7c0e9b2d4a6c8e0f1b3d5a7c9e1f3b5d7a9c1e3f5b7d9a1c3e5f7b9d1a3c
{
"event": "message.created",
"data": { ... },
"timestamp": "2026-03-14T12:00:00.000Z",
"accountId": "clx_acc1"
}Verifying webhook signatures
Compute the HMAC-SHA256 of the raw request body using your webhook secret, prefix it with sha256=, and compare it to the X-Deskwoot-Signature header with a constant time comparison. Use the raw body bytes, not a re-serialized JSON object.
import crypto from "crypto";
function verifyWebhook(rawBody, signatureHeader, secret) {
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
return (
signatureHeader.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expected))
);
}Retries and timeouts
Deskwoot waits up to 10 seconds for your endpoint to answer. Any non 2xx status, timeout or connection error triggers exactly one retry after 5 seconds with the same body and headers. If the retry fails too, the event is dropped and not queued again, so your endpoint should answer 2xx quickly and do the real work asynchronously. Redirects are not followed. The URL must be a public HTTPS endpoint; private and internal addresses are rejected when the webhook is created and again at delivery time.
Event types
Event names use dots (conversation.created). The underscored spelling (conversation_created) is accepted as an alias in subscriptions for older integrations, but the header and body always carry the dotted name. An empty subscriptions array subscribes the webhook to every event except the ticket events and the comments on tickets: a ticket event goes only to a webhook that names it.
| Event | Sent when |
|---|---|
| conversation.created | A new conversation was started: a customer wrote in on any channel, or it was created through the API |
| conversation.updated | A conversation's status, assignee, team, priority or other attributes changed |
| conversation.assigned | The assignee of a conversation changed (also sent when it was unassigned) |
| conversation.resolved | A conversation was marked as resolved |
| message.created | A message was sent or received in a conversation, including approved bot drafts |
| contact.created | A new contact was created |
| contact.updated | A contact's details were updated |
| csat.submitted | A customer submitted a satisfaction rating |
| ticket.created | A ticket was created from a conversation, or an interaction was filed as a resolved ticket (which also sends ticket.resolved) |
| ticket.updated | A ticket's status, assignee, team, category, priority, title, summary or other fields changed, or a Jira issue was raised from it, linked to it, unlinked from it or changed its status in Jira |
| ticket.assigned | The assignee of a ticket changed (also sent when it was unassigned) |
| ticket.resolved | A ticket was resolved |
A ticket never sends a conversation.* event: each change that would send one sends its ticket event instead. An internal comment on a ticket is a message.created event with conversationKind TICKET, sent only to a webhook that subscribes to message.created and at least one ticket event. The conversation events of the customer conversation a ticket came from stay as they are. Zapier gets no ticket events and no comments on tickets.
One change can send several ticket events (an assignee change sends ticket.updated and ticket.assigned, a filed ticket ticket.created and ticket.resolved). Each is delivered on its own, so they can arrive in any order: key on ticketId and read the state from ticket, which is the ticket when that event was sent.
Mail that was already in a mailbox when Deskwoot first reads it over IMAP (up to 30 days back) is imported without conversation.created and message.created, so your endpoint and your Zaps hear only mail that arrives after you connect it.
Event payloads
The examples below show the data object of each event. Full conversation, message and contact objects match the shapes returned by the REST endpoints above.
The customer sees that the AI assistant is an AI: a sentence under every email it writes, a line above its first reply in the messaging apps (and again after someone else answered), and ai_disclosure in the callback of a REST API channel inbox, for your own client to show. The event payloads carry the stored text, without that line.
{
"conversation": {
"id": "clx_conv1",
"displayId": 1042,
"accountId": "clx_acc1",
"inboxId": "clx_inbox1",
"contactId": "clx_contact1",
"status": "OPEN",
"priority": null,
"assigneeId": null,
"teamId": null,
"subject": "Order not delivered",
"createdAt": "2026-03-14T12:00:00.000Z"
},
"messageId": "clx_msg1"
}
// Started by a customer on any channel (live chat, email, WhatsApp,
// Telegram, LINE, SMS, X, the inbound API): a contact summary replaces
// messageId, and message.created follows for their first message
{
"conversation": { ... },
"contact": { "id": "clx_contact1", "name": "Anna Example", "email": "anna@example.com" }
}{
"conversationId": "clx_conv1",
"changes": {
"status": "PENDING",
"priority": "HIGH"
}
}
// "changes" contains the fields that changed: the ones sent in the PATCH
// request, or teamId when a transfer moves the conversation into a team{
"conversationId": "clx_conv1",
"assigneeId": "clx_user2",
"previousAssigneeId": "clx_user1"
}
// assigneeId is null when the conversation was unassigned{
"conversationId": "clx_conv1",
"resolvedBy": "clx_user2"
}{
"conversationId": "clx_conv1",
"message": {
"id": "clx_msg2",
"conversationId": "clx_conv1",
"senderId": "clx_user2",
"content": "Your order ships tomorrow.",
"contentType": "TEXT",
"messageType": "OUTGOING",
"private": false,
"status": "SENT",
"createdAt": "2026-03-14T12:05:00.000Z"
}
}{
"id": "clx_contact1",
"accountId": "clx_acc1",
"name": "Anna Example",
"email": "anna@example.com",
"phone": "+4930123456",
"company": null,
"location": null,
"identifier": null,
"customAttributes": {},
"createdAt": "2026-03-14T11:58:00.000Z"
}
// data is the full contact object itself{
"contact": {
"id": "clx_contact1",
"name": "Anna Example",
"email": "anna@example.com",
"phone": "+4930123456",
"company": "Example GmbH"
},
"changes": ["company", "phone"]
}
// "changes" lists the names of the fields that were sent in the update{
"ticketId": "clx_ticket1",
"ticket": {
"id": "clx_ticket1",
"number": 1043,
"title": "Charged twice for order 1001",
"status": "OPEN",
"priority": "HIGH",
"category": { "id": "clx_cat_billing", "name": "Billing" },
"team": { "id": "clx_team_accounts", "name": "Accounts" },
"assignee": { "id": "clx_user2", "name": "Ben" },
"creator": { "id": "clx_user1", "name": "Ana" },
"sourceConversationId": "clx_conv1",
"contactId": "clx_contact1",
"createdAt": "2026-10-19T09:00:00.000Z",
"resolvedAt": null,
"labels": [{ "id": "clx_label_vip", "title": "vip" }],
"customAttributes": { "order_id": "1001" },
"tracker": false,
"trackerIds": ["clx_tracker1"],
"linkedIssues": [
{
"key": "TAB-12",
"url": "https://acme.atlassian.net/browse/TAB-12",
"title": "Checkout fails on Safari",
"statusName": "In Progress",
"statusCategory": "IN_PROGRESS"
}
],
"creatorBot": null
},
"changes": { "priority": "HIGH" },
"source": "manual"
}
// "ticket" is the ticket when the event is sent. Besides it, each event
// carries what its conversation event would: ticket.updated "changes"
// (and "source" when a status change sent it), ticket.assigned
// "assigneeId" and "previousAssigneeId", ticket.resolved "resolvedBy" and
// "source". ticket.created carries the ticket only. The summary's text is
// never sent: "changes.summary" is true when it changed. A tracker has
// "tracker": true and null contactId and sourceConversationId; "trackerIds"
// names the trackers a ticket is linked to, and a link or unlink sends the
// tracker's ticket.updated with "changes": { "links": { "added", "removed" } }.
// "linkedIssues" lists the ticket's Jira issues, the first linked first,
// with their last known status. A raise, a link, an unlink and each new
// status from Jira send the ticket's ticket.updated with "changes":
// { "linkedIssues": { "change", "key", "statusName", "statusCategory" } },
// change being raised, linked, unlinked or status_changed; a new status
// has "source": "jira". "creatorBot" is the bot that created the ticket
// ({ "id", "name" }, Fynn), and "creator" is null then. New ticket keys are
// only ever added at the end, as labels, customAttributes and creatorBot were{
"conversationId": "clx_conv1",
"surveyId": "clx_survey1",
"rating": 5,
"feedback": "Fast and friendly, thank you!",
"contactId": "clx_contact1"
}
// feedback is null when the customer left no comment/v1/accounts/{accountId}/webhooksList all webhooks. Secrets are redacted to their first 4 characters.
Auth: Session or API token (scope: read), account member
/v1/accounts/{accountId}/webhooksCreate a webhook. A signing secret is generated automatically and returned once in the response.
Auth: Session or API token (scope: write), administrator role
| Parameter | Type | Required | Description |
|---|---|---|---|
| url | string | Required | The HTTPS URL to receive webhook events |
| subscriptions | string[] | Optional | Dotted event names from the list above. Underscored aliases such as conversation_created are accepted. Omit it or send an empty array to receive every event except the ticket events and the comments on tickets. An unknown name is refused with 400. |
curl -X POST https://deskwoot.com/api/v1/accounts/{accountId}/webhooks \
-H "Authorization: Bearer dw_your_token" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/deskwoot",
"subscriptions": ["message.created", "conversation.updated"]
}'{
"id": "clx_wh1",
"accountId": "clx_acc1",
"url": "https://your-app.com/webhooks/deskwoot",
"subscriptions": ["message.created", "conversation.updated"],
"secret": "3f9a1c...64 hex characters...b7e2",
"enabled": true,
"createdAt": "2026-03-14T12:00:00.000Z",
"updatedAt": "2026-03-14T12:00:00.000Z"
}/v1/accounts/{accountId}/webhooks/{webhookId}Update a webhook's URL, subscriptions or enabled flag. Below the Startup plan it answers 403 with the code feature_not_in_plan, like creating one.
Auth: Session (administrator role)
/v1/accounts/{accountId}/webhooks/{webhookId}Delete a webhook.
Auth: Session (administrator role)
API Tokens
/v1/accounts/{accountId}/api-tokensList all API tokens. The actual token value is not included for security.
Auth: Session (admin only)
[
{
"id": "clx_tk1",
"name": "Production Integration",
"scopes": ["read", "write"],
"lastUsedAt": "2026-03-14T10:00:00Z",
"expiresAt": null,
"createdAt": "2026-02-01T00:00:00Z"
}
]/v1/accounts/{accountId}/api-tokensCreate a new API token. The token value is only shown once in the response.
Auth: Session (admin only)
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Optional | A descriptive name for the token |
| scopes | string[] | Optional | Array of scopes (default: ["read", "write"]) |
| expiresAt | string | Optional | ISO date when the token expires (optional, never expires if omitted) |
/v1/accounts/{accountId}/api-tokens/{tokenId}Revoke an API token. It will immediately stop working.
Auth: Session (admin only)
Bots
/v1/accounts/{accountId}/botsList all AI bots. API keys are masked in the response.
Auth: Bearer token (read) or session
/v1/accounts/{accountId}/botsCreate a new AI bot.
Auth: Session (permission: bots.manage)
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Required | Bot name |
| aiProvider | string | Optional | AI provider: openai, anthropic, google, groq, or custom |
| aiApiKey | string | Optional | Your AI provider API key |
| aiModel | string | Optional | Model name (e.g. gpt-4o, claude-sonnet-4-20250514) |
| systemPrompt | string | Optional | Custom instructions for the bot |
| useHelpCenter | boolean | Optional | Let the bot use your help center articles as context |
| temperature | number | Optional | Creativity level 0-1 (default: 0.3) |
| maxTokens | number | Optional | Max response length 100-2000 (default: 500) |
/v1/accounts/{accountId}/bots/{botId}Update a bot's settings.
Auth: Session (permission: bots.manage)
/v1/accounts/{accountId}/bots/{botId}Delete a bot.
Auth: Session (permission: bots.manage)
/v1/accounts/{accountId}/bots/testTest a bot's response with a sample message.
Auth: Session (permission: bots.manage)
| Parameter | Type | Required | Description |
|---|---|---|---|
| botId | string | Required | Bot ID to test |
| message | string | Required | Test message |
Custom Attributes
A definition describes a field on conversations or contacts. The values live on each conversation or contact under customAttributes: set them with PATCH /v1/conversations/{conversationId}, POST /v1/accounts/{accountId}/conversations or the contact endpoints. Writes are merged into the stored values and null removes a key. Agents fill in the same fields from the conversation sidebar, and automation rules can use them as conditions.
| Parameter | Type | Required | Description |
|---|---|---|---|
| TEXT | string | Optional | Any text up to 1,000 characters |
| NUMBER | number | Optional | A number; numeric strings like "42" are accepted |
| LINK | string | Optional | A full http or https URL |
| DATE | string | Optional | A calendar day as YYYY-MM-DD |
| LIST | string | Optional | One of the definition's attributeValues |
| CHECKBOX | boolean | Optional | true or false |
A value that does not fit its definition is rejected with 400 and a message naming the key. Keys without a definition are stored as sent.
The AI bot never answers a customer from contact attributes written through the API or by your team. It reads only the attributes your site signs for a logged in visitor with Identity Verification (attributesHash, described in the widget docs).
/v1/accounts/{accountId}/custom-attributesList custom attribute definitions. Use ?model=CONVERSATION or ?model=CONTACT to filter.
Auth: Bearer token (conversations:read or contacts:read) or session
/v1/accounts/{accountId}/custom-attributesCreate a custom attribute definition.
Auth: Session (admin only)
| Parameter | Type | Required | Description |
|---|---|---|---|
| attributeDisplayName | string | Required | Display name shown in the UI |
| attributeKey | string | Required | Unique key used in API (snake_case) |
| attributeDisplayType | string | Optional | TEXT, NUMBER, LINK, DATE, LIST, or CHECKBOX (default: TEXT) |
| attributeModel | string | Optional | CONVERSATION or CONTACT (default: CONVERSATION) |
| attributeValues | string[] | Optional | The options of a LIST attribute (required for LIST, at most 100) |
/v1/accounts/{accountId}/custom-attributes/{attributeId}Update a custom attribute's display name, type, description or list options. Key and model cannot change.
Auth: Session (admin only)
/v1/accounts/{accountId}/custom-attributes/{attributeId}Delete a custom attribute.
Auth: Session (admin only)
Custom Roles
/v1/accounts/{accountId}/custom-rolesList all custom roles with their permissions.
Auth: Session (admin only)
/v1/accounts/{accountId}/custom-rolesCreate a custom role with granular permissions.
Auth: Session (admin only)
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Required | Role name |
| description | string | Optional | Role description |
| permissions | object | Optional | Permission flags (e.g. conversations.manage, settings.manage_inboxes) |
/v1/accounts/{accountId}/custom-roles/{roleId}Update a custom role.
Auth: Session (admin only)
/v1/accounts/{accountId}/custom-roles/{roleId}Delete a custom role.
Auth: Session (admin only)
Reports
conversations:read scope; contact names and emails are included only when the token also has contacts:read.All report endpoints read the same query parameters, and every filter combines with every other one. Inside one filter, separate several values with commas (they match any of them). A period holds the conversations created in it plus the ones resolved in it. Resolution times are measured on the conversations resolved in the period, CSAT on the surveys sent in it. A group chat (Telegram, LINE or WhatsApp group) is one conversation that continues at any age, so it has no resolution time. Status counts are the current status of the conversations counted. Times are in seconds.
Changes on 2026-10-12
- Per agent first response: in agent scoped results (
group_by=agent,agent_id, or a member who is no administrator)firstResponseis now the agent's own first response, measured from the moment the agent had the conversation, instead of the customer's wait on the conversations assigned to them. The same goes forfirst_response_*in the breakdown CSV,firstReplyAtandfirstResponseSecondsin conversation rows, and Avg Response Time (min) in the agents export. That cell is now empty, where it used to be 0, when the agent answered no waiting customer in the range. - Outside agent scoped results,
firstResponsenow runs from the customer's first message instead of the conversation's creation, so a conversation an agent started counts only once the customer has written.unansweredfollows the same rule: it counts only conversations where the customer asked something and nobody has answered. agent_idnow means the conversations the agent worked on in the period (replied in, was assigned, or held when it was resolved) instead of the conversations assigned to the agent today.- Agent groups overlap, so they do not add up to the totals.
Every field and CSV column keeps its name and position. New ones come last: replyTime, the reply_time_* CSV columns, agentFirstResponses on conversation rows, the agents export columns after Avg Response Time (min), and the replies endpoint.
Response times. Outside agent scoped results, firstResponseis the customer's wait: from the customer's first message to the first reply a person or an API integration sent, queue time included, measured on the conversations created in the period. A conversation an agent started counts from the customer's first answer, never from before it. replyTime is null there. In agent scoped results firstResponseis each agent's first reply to a waiting customer in a conversation, reported in the period that holds the reply, and replyTime covers every such reply in the period. An agent's reply counts from the oldest customer message still unanswered, but never from before that agent had the conversation, so time in the queue is never charged to an agent. A reply from someone who did not hold the conversation, or who took it with that reply, counts as 0 seconds and is counted in replyTime.pickedUp. A member's mail from their own mail client counts as their reply. Bot answers, automation messages, automatic mails such as out of office notices, and private notes never count and never end a wait. A reply from an API integration, a mail sent from an address that is no member's, and a resolve end the wait without counting for anyone. The customer's wait belongs to the conversation, so agent scoped results do not repeat it; read it from a result without agent_id.
With agent_id, the conversations counted (conversations, created, resolved, resolutionRate) are the ones the agent worked on in the period, wherever they were created, and allTimeConversations counts the conversations the agent ever held.
| Parameter | Type | Required | Description |
|---|---|---|---|
| from | string | Optional | Start of the period: a date (YYYY-MM-DD, start of that day in timezone) or an ISO 8601 timestamp |
| to | string | Optional | End of the period: a date (inclusive, the whole day) or an ISO 8601 timestamp (exclusive). Default: now |
| days | number | Optional | Instead of from/to: the last N days up to now (1 to 366, default 30) |
| timezone | string | Optional | IANA timezone for dates and day/week buckets, e.g. Europe/Berlin (default UTC) |
| agent_id | string | Optional | Agent ids: the conversations the agent worked on in the period (replied in, was assigned, or held when it was resolved), and for the response times only that agent's replies; unassigned matches the period's conversations nobody worked on |
| team_id | string | Optional | Team ids; none matches conversations without a team |
| inbox_id | string | Optional | Inbox ids |
| channel | string | Optional | WEBSITE, EMAIL, WHATSAPP, WHATSAPP_TWILIO, TELEGRAM, TWITTER, SMS, LINE, API |
| label_id | string | Optional | Label ids (conversations with any of them); none matches conversations without a label |
| status | string | Optional | OPEN, PENDING, RESOLVED, SNOOZED |
| priority | string | Optional | urgent, high, medium, low; none matches conversations without a priority |
| type | string | Optional | conversations (default), tickets or all, on summary, breakdown, conversations, replies and csat. Without it they count customer conversations only |
| category_id | string | Optional | Category ids of conversations or tickets; none matches the ones without a category |
| ticket_status_id | string | Optional | Custom ticket status ids; none matches tickets in a plain base status and every conversation |
| hours | string | Optional | calendar (default) or business: summary and breakdown count the response and resolution times only inside business hours. Other endpoints answer 400 to business |
Business hours: with hours=businessfirstResponse, nextResponse, resolution and replyTime count only the business hours of each conversation: its team's schedule, else its inbox's business hours (conversations only, when switched on), else the schedule of the SLA policy that applies while it counts business hours only, else every hour. Team and policy schedules count on plans with SLA. Counts stay the same, and time in team stays calendar time. nextResponseis the customer's wait for each reply after the first response, from the oldest message still unanswered, over the replies sent in the period; it counts the replies replyTimecounts (every person's, or in agent scoped results the agent's own). In a merged conversation the first reply each chat got before the merge was its own first response and is left out. A ticket has none, so with type=tickets it is null.
Tickets: with type=tickets the summary, breakdown, conversations, replies and csat endpoints count tickets instead of conversations (article-feedback reads the period only, and export has its own kind), and group_by=category or group_by=teamgives tickets by category or by team with their counts, resolved tickets and resolution times. A ticket has no channel, no customer waiting for a first reply and no survey. A ticket filed with resolve-as-ticket begins at the interaction's first customer message, so its resolution time is the interaction's.
Reopens and backlog: reopenscounts the status changes out of resolved in the period (a move to spam is none), from the status history, which starts with the release that records it, and rates them over the resolves of the period in that history, whatever the status now; a resolve from before that history counts nowhere, even once it is reopened. A group chat's conversation has no reopens, its tickets do. An import counts from its first message written here, and the reopen of a resolve made in the other helpdesk is none. backlog is what is open or pending now, whatever the period, aged from the last reopen or else the creation, in 4 buckets with the oldest item; snoozed items and imports nobody has written in since are left out. In a breakdown both follow the group the conversation has now (per agent, the agent who holds it); a day or week has no backlog.
Escalations and time in team: teamMoves comes from the team history, which starts with the release that records it. An escalation is a move to another team than the one before (also after a while without a team), dated when it happened; the first team is none. timeInTeamtimes each stretch in a team inside the period, up to now, less the time the item was resolved (from the status history); a group chat's conversation has none, its tickets do. Per team and with team_id, moves in and time in team follow the team the conversation was in and moves out the team it left; every other filter and group reads the conversation as it is now. An import counts from its first message written here, so imports nobody has written in since are left out, and a day or week has no team moves.
SLA compliance: sla comes from the stored SLA outcomes. It counts the SLA clocks that stopped in the period, met or breached, and the breaches with no recorded end that started in it (running): a breach has none while its clock runs, or when the SLA stopped timing the conversation before its clock stopped (its policy removed, a plan without SLA, or marked as spam). A clock still inside its target counts once it stops. compliance is the met ones over all of them, per metric, for conversations and tickets apart and together (total). The times are the SLA's own (pauses left out, and the time outside business hours for a policy that counts business hours only), so hours=business changes nothing here. In a breakdown it follows the group the conversation has now; a day or week holds the clocks that stopped on it and the breaches that started on it with no recorded end. On a plan without SLA, sla is null and the sla_* CSV cells stay empty, also after a downgrade.
/v1/accounts/{accountId}/reports/summaryAll headline numbers for the period and filters, plus the number of matching conversations of all time.
Auth: Bearer token (conversations:read) or session
| Parameter | Type | Required | Description |
|---|---|---|---|
| metrics | string | Optional | core, here and on the breakdown, for the light numbers only: counts, the customer's first response, resolution time and CSAT. replyTime, reopens, backlog, teamMoves, nextResponse and sla come back null (in the breakdown CSV their cells stay empty), and an agent scoped firstResponse comes back empty; the shape stays the same |
curl "https://deskwoot.com/api/v1/accounts/{account_id}/reports/summary?from=2026-09-01&to=2026-09-30&timezone=Europe/Berlin&channel=email,whatsapp" \
-H "Authorization: Bearer dw_your_api_token_here"
{
"period": { "from": "2026-08-31T22:00:00.000Z", "to": "2026-09-30T22:00:00.000Z", "timezone": "Europe/Berlin" },
"filters": { "channel": ["EMAIL", "WHATSAPP"] },
"metrics": {
"conversations": 412, "created": 398, "resolved": 371, "resolutionRate": 0.9005,
"status": { "open": 25, "pending": 9, "snoozed": 2, "resolved": 376 },
"unanswered": 4,
"firstResponse": { "count": 390, "avgSeconds": 1840, "medianSeconds": 620, "p90Seconds": 5400,
"minSeconds": 12, "maxSeconds": 86000, "over1h": 61, "over4h": 14, "over24h": 1 },
"resolution": { "count": 371, "avgSeconds": 52000, "medianSeconds": 14400, "p90Seconds": 172800,
"minSeconds": 300, "maxSeconds": 900000 },
"csat": { "sent": 300, "responded": 120, "responseRate": 0.4, "avgRating": 4.6, "score": 92,
"satisfiedRate": 0.9167, "distribution": { "1": 2, "2": 1, "3": 7, "4": 20, "5": 90 } },
"replyTime": null,
"reopens": { "reopened": 19, "resolves": 371, "rate": 0.0512 },
"backlog": { "unresolved": 34, "buckets": { "under1d": 18, "from1to3d": 9, "from3to7d": 5, "over7d": 2 },
"oldest": { "id": "cm1abc", "since": "2026-09-18T09:12:00.000Z", "ageSeconds": 1080000 } },
"teamMoves": { "escalations": 12, "escalatedOut": 12,
"timeInTeam": { "count": 140, "avgSeconds": 151200, "medianSeconds": 86400, "p90Seconds": 432000,
"minSeconds": 600, "maxSeconds": 2592000 } },
"nextResponse": { "count": 820, "avgSeconds": 2400, "medianSeconds": 900, "p90Seconds": 7200,
"minSeconds": 5, "maxSeconds": 172800 },
"sla": {
"total": { "firstResponse": { "achieved": 352, "breached": 38, "running": 2, "compliance": 0.9026 }, "nextResponse": { ... }, "resolution": { ... } },
"conversations": { "firstResponse": { ... }, "nextResponse": { ... }, "resolution": { ... } },
"tickets": { "firstResponse": { ... }, "nextResponse": { ... }, "resolution": { ... } }
}
},
"allTimeConversations": 5210
}curl "https://deskwoot.com/api/v1/accounts/{account_id}/reports/summary?days=7&agent_id={agent_id}" \
-H "Authorization: Bearer dw_your_api_token_here"
{
...
"metrics": {
...
"firstResponse": { "count": 38, "avgSeconds": 95, "medianSeconds": 48, "p90Seconds": 240,
"minSeconds": 0, "maxSeconds": 1800, "over1h": 0, "over4h": 0, "over24h": 0 },
...
"replyTime": { "count": 112, "avgSeconds": 130, "medianSeconds": 60, "p90Seconds": 420,
"minSeconds": 0, "maxSeconds": 3600, "pickedUp": 9 }
},
...
}/v1/accounts/{accountId}/reports/breakdownThe same metrics per group. Groups without activity are included with zeros, so quiet agents and unused labels show up. An agent group holds the conversations that agent worked on in the period, so agent groups overlap and do not add up to the totals; the group without a key, Unassigned, holds the period's conversations nobody worked on. Agent groups are agent scoped: firstResponse is the agent's own first response and replyTime their reply times. Day and week buckets follow the timezone; weeks start on Monday. The CSV keeps its columns in place; the reply_time_* columns follow and stay empty when the result is not agent scoped, then the reopen and backlog columns, then escalations, escalated_out and the time_in_team_* columns (backlog and team moves empty for a day or week), then the next_response_* columns, then the sla_* columns (achieved, breached and compliance per metric, conversations and tickets together).
Auth: Bearer token (conversations:read) or session
| Parameter | Type | Required | Description |
|---|---|---|---|
| group_by | string | Required | agent, team, inbox, channel, label, status, priority, category, day, week or ticket_status (the custom ticket status; with type=tickets the volume per queue) |
| format | string | Optional | csv for one row per group |
/v1/accounts/{accountId}/reports/conversationsOne row per conversation of the period with every fact the reports are built from: status, priority, subject, agent (the current assignee), team, inbox, channel, labels, first reply and first response, resolution time, CSAT rating and feedback, country and, with contacts:read, the contact. firstReplyAt and firstResponseSeconds are the conversation's first reply and the customer's wait; with agent_id (the conversations the agent worked on in the period) or for a member who is no administrator, they are the agent's own first reply to a waiting customer and its wait, empty when the agent gave none. agentFirstResponses, last in each row, lists each agent's own first response with seconds, repliedAt and assignedAt. Newest first. With slice, the rows are the conversations behind one number of the summary, counted the same way, and each row ends with openSince (since when a backlog row is open, null outside the backlog slices) and sliceEvents (how often the row counts in a reopened or sla slice, null in any other); the first page adds total (the conversations of the slice) and, for reopened and sla, events (the reopens or SLA outcomes they hold, the number the summary shows). The CSV of a slice appends open_since and slice_events after agent_first_responses. Without slice the answer is what it always was.
Auth: Bearer token (conversations:read) or session
| Parameter | Type | Required | Description |
|---|---|---|---|
| slice | string | Optional | all (the period's conversations), created, resolved, unanswered, waited_over:3600, waited_over:14400 or waited_over:86400 (created in the period, first response longer than that many seconds), reopened (reopened in the period), sla:<first_response|next_response|resolution|all>:<met|breached|running> (SLA outcomes of the period, on a plan with SLA; 403 otherwise), backlog or backlog_age:<under1d|1to3d|3to7d|over7d> (open or pending now, whatever the period) and created_hour:<1 to 7>:<from>-<to> (created on that ISO weekday, at a local hour from from to before to, in timezone) |
| sort | string | Optional | created_desc (default), first_response_desc or resolution_desc (the longest first, rows without one last, in calendar hours), open_for_desc (open the longest first, backlog slices only) |
| limit | number | Optional | Rows per page (1 to 500, default 100) |
| cursor | string | Optional | nextCursor of the previous page, in the same sort |
| format | string | Optional | csv for every row of the period in one file |
curl "https://deskwoot.com/api/v1/accounts/{account_id}/reports/conversations?days=30&slice=waited_over:3600&sort=first_response_desc&limit=50" \
-H "Authorization: Bearer dw_your_api_token_here"
{
"period": { ... },
"filters": {},
"conversations": [
{ "id": "...", "displayId": 4907, ..., "firstResponseSeconds": 18720, ..., "agentFirstResponses": [], "openSince": null, "sliceEvents": null }
],
"nextCursor": "WyIyMDI2LTEwLTIwVDA5OjAwOjAwLjAwMFoiLCIuLi4iXQ",
"total": 61
}/v1/accounts/{accountId}/reports/repliesOne row per reply that counts in the agent response times: a person answering a customer who was waiting, sent in the period. Each row has messageId, conversation, agent, repliedAt, waitingSince (the oldest customer message still unanswered), assignedAt (when the agent got the conversation; null when they took it by answering), agentWaitSeconds, customerWaitSeconds (queue time included), firstForAgent, inbox and team. agent_id picks whose replies are listed; the other filters apply to the conversation. Newest first. A member who is no administrator gets only their own replies in the conversations assigned to them.
Auth: Bearer token (conversations:read) or session
| Parameter | Type | Required | Description |
|---|---|---|---|
| limit | number | Optional | Rows per page (1 to 500, default 100) |
| cursor | string | Optional | nextCursor of the previous page |
| format | string | Optional | csv for every reply of the period in one file |
/v1/accounts/{accountId}/reports/csatCSAT totals and star distribution, the numbers per agent (the agent the survey was sent for) and every survey of the period with rating, feedback, agent, conversation, inbox, team and, with contacts:read, the contact. A survey that waited for the conversation's open tickets names the ticket whose resolve sent it in ticketId (null otherwise).
Auth: Bearer token (conversations:read) or session
| Parameter | Type | Required | Description |
|---|---|---|---|
| responded | boolean | Optional | true keeps only rated surveys |
| rating | number | Optional | 1 to 5 keeps only the surveys rated so, the ones that star counts in the distribution |
| limit | number | Optional | Surveys per page (1 to 500, default 100) |
| cursor | string | Optional | nextCursor of the previous page |
| format | string | Optional | csv for every survey of the period in one file |
/v1/accounts/{accountId}/reports/article-feedbackHelp center article votes (helpful and not helpful) per article in the period. Reads the period parameters only.
Auth: Bearer token (help_center:read) or session
/v1/accounts/{accountId}/reports/exportThe CSV downloads of the Reports page: type=conversations, agents or csat with range=7d, 30d or 90d. For type=conversations, kind=conversations (the default), tickets or all says what the file holds, as the Reports page's Type switch; with tickets the file gets the columns Type, Title, Category and Source Conversation ID after the published ones, and a ticket's Inbox is empty. An unknown kind answers 400. The agents file has one row per person and keeps its first six columns in place: Agent Name, Email, Role, Conversations (worked on in the range), Resolved, Avg Response Time (min) (the agent's own first response in whole minutes, empty when there is none). Then come Replies, Picked Up Replies, First Response Avg, Median and P90, Reply Time Avg and Median (in seconds), CSAT Score and CSAT Responses; format=json returns the same numbers.
Auth: Bearer token (conversations:read) or session
Reports never reach past your plan's data retention. A period can cover up to 366 days; for longer histories, request one year at a time. A CSV holds up to 100,000 rows; for a bigger period, use a shorter one or page through the JSON response. Each workspace can make 120 report requests a minute and 30 CSV downloads an hour; above that the API answers 429 with a Retry-After header. Timezones are IANA names such as Europe/Berlin; offsets like +01:00 are not accepted. Signed in to the dashboard, a CSV needs the Reports export permission as well; an API token downloads as before.
SLA Policies
/v1/accounts/{accountId}/sla-policiesList all SLA policies with conversation counts.
Auth: Bearer token (read) or session
/v1/accounts/{accountId}/sla-policiesCreate an SLA policy.
Auth: Session (admin only)
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Required | Policy name |
| description | string | Optional | Policy description |
| firstResponseTimeThreshold | number | Optional | Max seconds for first response |
| nextResponseTimeThreshold | number | Optional | Max seconds between responses |
| resolutionTimeThreshold | number | Optional | Max seconds to resolve |
| onlyDuringBusinessHours | boolean | Optional | Only count time during business hours (default: false) |
| priorityTargets | object | Optional | Own targets for MEDIUM, HIGH or URGENT conversations, like { "HIGH": { "firstResponse": 30, "nextResponse": 60, "resolution": 480 } }, each a whole number of minutes from 1 to 43200. A priority or target left out uses the policy's own target, as do LOW and no priority. Null removes them all. |
| businessHoursScheduleId | string | Optional | A business hours schedule of this workspace that the clocks count in under onlyDuringBusinessHours, after the team's schedule and the inbox's business hours. Null for none. |
/v1/accounts/{accountId}/sla-policies/{policyId}Update an SLA policy. Takes the same fields as POST, priorityTargets and businessHoursScheduleId included; a field left out stays as it is.
Auth: Session (admin only)
/v1/accounts/{accountId}/sla-policies/{policyId}Delete an SLA policy.
Auth: Session (admin only)
Business hours schedules
Named weeks the SLA clocks count in when a policy has onlyDuringBusinessHours. A team and a policy each point to one with businessHoursScheduleId. The clocks use the team's schedule first, then the inbox's business hours (conversations only), then the policy's schedule, and count every hour when none is set. A workspace has at most 20 schedules, each name once.
/v1/accounts/{accountId}/business-hours-schedulesThe schedules by name: id, name, timezone, schedule (the 7 days) and _count with the teams and policies that use each.
Auth: Session (permission: settings.manage_teams)
/v1/accounts/{accountId}/business-hours-schedulesAdd a schedule. 409 schedule_limit at 20 schedules, 409 schedule_name_taken when the name exists already. A refused time answers 400 with a code and the field's path.
Auth: Session (admin only), plan with SLA policies
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Required | 1 to 255 characters |
| timezone | string | Required | A time zone like Europe/Berlin |
| schedule | array | Required | The 7 days of the week, each like { "day": "Monday", "enabled": true, "allDay": false, "openTime": "09:00", "closeTime": "17:00" } |
/v1/accounts/{accountId}/business-hours-schedules/{scheduleId}Change a schedule's name, timezone or days; the fields left out stay. The clocks that count in it follow at their next read. 409 schedule_name_taken when the name exists already.
Auth: Session (admin only), plan with SLA policies
/v1/accounts/{accountId}/business-hours-schedules/{scheduleId}Delete a schedule. The teams and policies that used it lose it, and their clocks count in the next schedule of the order. Works without the plan, so a workspace can tidy up after a downgrade.
Auth: Session (admin only)
Business Hours
/v1/accounts/{accountId}/business-hoursGet your account's business hours configuration.
Auth: Bearer token (read) or session
{
"enabled": true,
"timezone": "Europe/Berlin",
"schedule": [
{ "day": "monday", "enabled": true, "openHour": 9, "openMinute": 0, "closeHour": 17, "closeMinute": 0 },
{ "day": "tuesday", "enabled": true, "openHour": 9, "openMinute": 0, "closeHour": 17, "closeMinute": 0 },
{ "day": "saturday", "enabled": false, "openHour": 0, "openMinute": 0, "closeHour": 0, "closeMinute": 0 },
{ "day": "sunday", "enabled": false, "openHour": 0, "openMinute": 0, "closeHour": 0, "closeMinute": 0 }
]
}/v1/accounts/{accountId}/business-hoursSet business hours. Provide the full schedule.
Auth: Session (permission: settings.manage_account)
Help Center (Portals)
/v1/accounts/{accountId}/portalsList your help center portals with article and category counts.
Auth: Bearer token (help_center:read) or session
/v1/accounts/{accountId}/portalsCreate a new help center portal.
Auth: Bearer token (help_center:write) or session
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Required | Portal name |
| slug | string | Required | URL slug (e.g. 'help') |
| headerText | string | Optional | Header text shown on the portal |
| brandColor | string | Optional | Brand color hex (default: "#2563EB") |
| customDomain | string | Optional | Custom domain for the portal |
/v1/accounts/{accountId}/portals/{portalId}Update portal settings, including which languages it offers translations in.
Auth: Bearer token (help_center:write) or session
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Optional | Portal name |
| pageTitle | string | Optional | Large heading shown to visitors (defaults to the name) |
| headerText | string | Optional | Subtitle shown under the title |
| brandColor | string | Optional | Brand color hex |
| faviconUrl | string | Optional | Absolute https URL to the favicon shown in the browser tab (square PNG/ICO). Send empty to clear it. The Help Center then shows a generated branded icon, never the Deskwoot one. |
| showChatWidget | boolean | Optional | Show your chat widget (the same one you embed on your website) on the Help Center pages. Requires a live chat (website) inbox; if none exists nothing is shown. |
| articleSidebar | boolean | Optional | Article pages get a left "In this category" sidebar listing sibling articles (navigation like a docs site). Only appears when a category has more than one article. |
| homePageLink | string | Optional | URL the portal name / logo links back to (usually your main website). |
| topPillLabel | string | Optional | Small pill shown above the headline (announcement / tagline). Empty hides the pill. |
| topPillBg | string | Optional | Background color hex for the top pill. |
| topPillText | string | Optional | Text color hex for the top pill. |
| searchEnabled | boolean | Optional | Show the search bar on the home page (default true). |
| searchPlaceholder | string | Optional | Placeholder text for the search input. |
| quickJumpEnabled | boolean | Optional | Show the quick-jump link row under the search bar. |
| quickJumpItems | object[] | Optional | Quick-jump links: [{ label, url }]. Sanitized on save. Send null to clear. |
| faqEnabled | boolean | Optional | Show the FAQ accordion on the home page. |
| faqItems | object[] | Optional | FAQ entries: [{ question, answer }]. answer keeps a curated rich-text allow-list; an optional per-locale i18n map ({ question, answer }) is supported. Send null to clear. |
| promoBannerEnabled | boolean | Optional | Show the promo banner. |
| promoBanner | object | Optional | Promo banner content: { headline, body, ctaLabel, ctaUrl }. body keeps a curated rich-text allow-list. Send null to clear. |
| enabledLocales | string[] | Optional | Languages the help center is translated into, e.g. ["en", "de", "fr", "ja", "zh-CN"]. English is always kept; unknown codes are dropped. Supported: en, ar, de, es, fr, hi, id, ja, ko, mn, pt-BR, ru, tr, uz, zh-CN, zh-TW. |
| localeControlledExternally | boolean | Optional | When true, the public article page hides its own language switcher and serves the language from ?locale=. Use it when the help center is on a custom domain and the parent site already controls the language. |
| categoryCardLayout | boolean | Optional | true renders categories as a Browse-by-Topic card grid (each card links to /category/<slug>); false (default) keeps the inline article list. |
| featuredArticleId | string | Optional | Id of a published article in this portal to feature in a "Start Here" block at the top of the help center. Send null to clear it. |
| fontFamily | string | Optional | Web font for the public help center: "" (system default), Inter, Roboto, Open Sans, Lato, Poppins, Montserrat, Nunito, Source Sans 3, or Plus Jakarta Sans. Unknown values fall back to the system font. |
| latestGuidesEnabled | boolean | Optional | Adds a “Latest Guides” grid of recent article cards (cover image, category, title, excerpt, author, date) below the categories. |
| heroAlignment | string | Optional | "left" or "center" (default) for the title / subtitle / search bar. |
| theme | string | Optional | "light" (default), "dark", or "auto" (follows the visitor’s prefers-color-scheme). Applies on every public page. |
| themeTokens | object | Optional | Optional per-token color overrides for the theme: { pageBg, surface, border, text, muted, heading } as hex. Unknown keys / non-hex values are dropped; omit for the built-in palette. Send null to clear. |
| sectionOrder | string[] | Optional | Order of the home page sections. Valid keys: pill, headline, subheader, search, featured, quickJump, faq, categories, latestGuides, promo. Sections whose content/toggle is off are skipped. Example: ["search","categories","featured","latestGuides"]. |
| customCss | string | Optional | Scoped CSS injected on every public help-center page. @import, javascript: URLs, and external resource fetches are stripped on save. Max 20,000 characters. Send null to clear. |
| whiteLabel | boolean | Optional | Enterprise. Removes the “Powered by Deskwoot” credit from the footer. 402 if the plan is below Enterprise. |
| customFooterEnabled | boolean | Optional | Replace the default footer with a custom one (columns/bottom text below, or customFooterHtml). |
| customFooterColumns | object[] | Optional | Up to 4 footer link columns: [{ title, links: [{ label, url }] }] (max 8 links per column). Sanitized on save. Send null to clear. |
| customFooterBottomText | string | Optional | A single copyright / legal line rendered below the footer columns. Max 500 characters. Send null to clear. |
| customFooterHtml | string | Optional | Raw footer HTML so the help center mirrors the footer of your own marketing site. When set, it REPLACES customFooterColumns / customFooterBottomText on the public page. Sanitized on save (scripts, event handlers, <style>, and javascript: URLs stripped; class + style attributes survive so customCss can finish the styling). Max 20,000 characters. Two requirements when setting a non-empty value: the portal must run on an ACTIVE custom domain (409 otherwise, it stays hidden on the *.deskwoot.com subdomain) and the plan must be Enterprise (402 otherwise). Send null to clear. |
| customHeaderHtml | string | Optional | Raw header HTML rendered above the help center, so it can carry the navbar of your own marketing site. Same sanitizer as customFooterHtml (no script/style/iframe/form/handlers; class + inline style survive so customCss finishes the styling). Max 20,000 characters. Same two gates: an ACTIVE custom domain (409 otherwise) and an Enterprise plan (402 otherwise). Send null/empty to clear (no header is shown, there is no default). |
| published | boolean | Optional | true = live, false = draft (public URL 404s) |
/v1/accounts/{accountId}/portals/{portalId}/articlesList articles in a portal.
Auth: Bearer token (help_center:read) or session
/v1/accounts/{accountId}/portals/{portalId}/articlesCreate a new help center article.
Auth: Bearer token (help_center:write) or session
| Parameter | Type | Required | Description |
|---|---|---|---|
| title | string | Required | Article title (used for the H1, page title and SEO title) |
| navTitle | string | Optional | Optional short label for the "In this category" sidebar (e.g. "Bybit"). Falls back to title when empty. The H1 / SEO title are unaffected. Per-locale overrides go in translations.{locale}.navTitle. Batch-set this across a category whose titles share a pattern. |
| content | string | Required | Article body (HTML; <details>/<summary> are allowed for FAQ accordions) |
| slug | string | Optional | URL slug (auto-generated from title if omitted) |
| categoryId | string | Required | Category the article belongs to (create one via the categories endpoint first) |
| status | string | Optional | DRAFT, PUBLISHED, or ARCHIVED (case-insensitive, default: DRAFT) |
| translations | object | Optional | Per-language overlay keyed by locale code, e.g. { "de": { "title": "...", "navTitle": "...", "content": "...", "description": "...", "metaTitle": "...", "metaDescription": "..." } }. English stays in the top-level fields above and is ignored here; unknown locale codes are dropped. On PATCH this merges into the stored translations one field at a time: only the locales and fields you actually include change, and everything else is preserved. So { "de": { "navTitle": "LeveX" } } sets just de.navTitle and leaves the rest of the German translation intact. To delete a whole language send { "de": null }; to clear every translation send "translations": null. The result is served via ?locale= on the public endpoints. |
/v1/accounts/{accountId}/portals/{portalId}/articles/{articleId}Update an article, including its per-language translations object. A new slug moves every language row of the article (the rows that share its slug) with it; 409 with code slug_taken when another article already uses that slug in one of those languages. A move that makes an internal article public (its last row in an internal category moved to a public one) needs confirmPublic set to true; without it the request changes nothing and answers 409 with code confirm_public and articles, the number of articles it would publish.
Auth: Bearer token (help_center:write) or session
/v1/accounts/{accountId}/portals/{portalId}/articles/{articleId}Delete an article.
Auth: Bearer token (help_center:write) or session
/v1/accounts/{accountId}/portals/{portalId}/categoriesList categories in a portal, ordered by position.
Auth: Bearer token (help_center:read) or session
/v1/accounts/{accountId}/portals/{portalId}/categoriesCreate a category. Every article must belong to a category, so create one before creating articles.
Auth: Bearer token (help_center:write) or session
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Required | Category name |
| slug | string | Required | URL slug (e.g. 'getting-started') |
| description | string | Optional | Short description shown under the category title |
| locale | string | Optional | Language code (default: "en") |
| icon | string | Optional | Icon key shown on the category card: book, rocket, sparkles, gear, chat, shield, wallet, credit-card, chart, key, lifebuoy, globe, bolt, or document. Unknown values fall back to the book icon. |
/v1/accounts/{accountId}/portals/{portalId}/categories/{categoryId}Update a category, including who may read it.
Auth: Bearer token (help_center:write) or session
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Optional | Category name |
| slug | string | Optional | URL slug. 409 with code slug_taken when another category of the same language uses it. |
| description | string | Optional | Short description shown under the category title |
| icon | string | Optional | Icon key shown on the category card (see create) |
| visibility | string | Optional | PUBLIC or INTERNAL. An internal category, every category below it and all their articles are for your team only: they stay off the public help center, its sitemap and search, the chat widget, the public APIs and the AI bot. The category's translations (categories with the same slug) switch with it. |
| confirmPublic | boolean | Optional | Must be true to switch an internal category to PUBLIC. Without it the request changes nothing and answers 409 with code confirm_public and articles, the number of articles the switch would publish. |
Public Portal API (no authentication required)
These endpoints are publicly accessible and designed for embedding your help center in external websites or apps.
/v1/public/portals/{portalSlug}Get public portal info with categories and article count.
Auth: None
/v1/public/portals/{portalSlug}/articlesList published articles. Supports search and pagination.
Auth: None
| Parameter | Type | Required | Description |
|---|---|---|---|
| categoryId | string | Optional | Filter by category |
| search | string | Optional | Search articles by title or content |
| page | number | Optional | Page number (default: 1) |
| limit | number | Optional | Items per page (default: 20, max: 50) |
| locale | string | Optional | Language to return titles/descriptions in (e.g. en, de, fr, ja, zh-CN). Falls back to English when a field is not translated. Defaults to the Accept-Language header, then en. |
/v1/public/portals/{portalSlug}/articles/{articleSlug}Get a single published article by slug, with its title, body, and SEO fields in the requested language.
Auth: None
| Parameter | Type | Required | Description |
|---|---|---|---|
| locale | string | Optional | Language to return the article in. Falls back to English for fields that are not translated. Defaults to the Accept-Language header, then en. |
Account Settings
/v1/accounts/{accountId}/settingsGet account settings including name, locale, timezone, plan, and feature flags.
Auth: Bearer token (read) or session
{
"id": "clx_acc1",
"name": "Acme Support",
"locale": "en",
"timezone": "Europe/Berlin",
"plan": "ENTERPRISE",
"autoResolveEnabled": true,
"autoResolveDuration": 48,
"brandColor": "#2563EB",
"liveTranslateEnabled": true,
"liveTranslateTargetLang": "en",
"createdAt": "2026-01-01T00:00:00Z"
}/v1/accounts/{accountId}/settingsUpdate account settings.
Auth: Session (permission: settings.manage_account)
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Optional | Account name |
| locale | string | Optional | Default locale (e.g. en, de) |
| timezone | string | Optional | IANA timezone (e.g. Europe/Berlin) |
| autoResolveEnabled | boolean | Optional | Auto-resolve inactive conversations |
| autoResolveDuration | number | Optional | Hours of inactivity before auto-resolve |
| brandColor | string | Optional | Brand color of the chat widget: #rgb or #rrggbb, stored as #rrggbb in lower case. A color with transparency is refused |
| liveTranslateEnabled | boolean | Optional | Enable live translation |
| liveTranslateTargetLang | string | Optional | Target language for translation |
Notifications
/v1/accounts/{accountId}/notificationsGet paginated notifications for the current user.
Auth: Session only
{
"data": [
{
"id": "clx_notif1",
"type": "conversation_assigned",
"read": false,
"createdAt": "2026-03-14T10:00:00Z",
"conversation": { "id": "...", "contactName": "Lisa" }
}
],
"meta": {
"page": 1,
"pageSize": 20,
"total": 45,
"unreadCount": 3
}
}/v1/accounts/{accountId}/notificationsMark notifications as read.
Auth: Session only
| Parameter | Type | Required | Description |
|---|---|---|---|
| markAllRead | boolean | Optional | Set to true to mark all as read |
| notificationId | string | Optional | Or mark a single notification as read |
Audit Logs
/v1/accounts/{accountId}/audit-logsGet paginated audit logs for your account. Admin only.
Auth: Session (admin only)
| Parameter | Type | Required | Description |
|---|---|---|---|
| page | number | Optional | Page number (default: 1) |
{
"data": [
{
"id": "clx_log1",
"action": "inbox.created",
"actor": { "name": "Anna", "email": "anna@example.com" },
"details": { "inboxName": "WhatsApp" },
"createdAt": "2026-03-14T08:00:00Z"
}
],
"meta": { "page": 1, "pageSize": 25, "total": 200, "totalPages": 8 }
}Billing
/v1/accounts/{accountId}/billingGet billing information for your account.
Auth: Session (permission: settings.manage_billing)
{
"plan": "ENTERPRISE",
"status": "ACTIVE",
"agentSeats": 10,
"activeAgents": 4,
"trialEndsAt": null,
"currentPeriodEnd": "2026-04-01T00:00:00Z"
}/v1/accounts/{accountId}/ai-billingGet the unified AI conversation meter, prepaid balance, and auto-recharge config. One conversation counts at most once per calendar month, regardless of whether the live-chat bot answered, an agent ran a Copilot action, or both. Answers in the help center search count 1 for a visitor's first question of the month and 0.25 for each further one, so conversationsUsed and overageConversations can hold quarters such as 12.25.
Auth: Session (member of account)
{
"plan": "ENTERPRISE",
"conversationsUsed": 299,
"conversationsIncluded": 300,
"periodStart": "2026-05-01T00:00:00Z",
"resetsAt": "2026-06-01T00:00:00Z",
"unlimited": false,
"overageConversations": 0,
"overagePrice": 0.03,
"overageCostUsd": 0,
"prepaidBalance": 0.14,
"topUpAllowed": true,
"autoRecharge": {
"enabled": true,
"amount": 100,
"maxPerMonth": 5,
"currentCount": 0
},
"usageHistory": [
{
"periodStart": "2026-04-01T00:00:00Z",
"periodEnd": "2026-05-01T00:00:00Z",
"conversationsUsed": 287,
"conversationsIncluded": 300,
"overageConversations": 0,
"overageCostUsd": 0
}
]
}Plan caps: Hacker 10/month (no overage), Startup 50 ($0.07/conv), Business 100 ($0.05/conv), Enterprise 300 ($0.03/conv). periodStart, resetsAt: the counters run per calendar month in UTC on every plan and reset at resetsAt (the end of a no card trial also starts a new period). Finished periods move to usageHistory. topUpAllowed: false on Hacker. Calling /v1/stripe/topup on a Hacker account returns 402 with upgradeRequired: true.
/v1/accounts/{accountId}/ai-billingUpdate auto-recharge settings. The legacy addBalance field is rejected with 410 Gone, top-ups must go through /v1/stripe/topup so Stripe is the source of truth for balance increments.
Auth: Session (permission: ADMINISTRATOR)
{
"autoRechargeEnabled": true,
"autoRechargeAmount": 100,
"autoRechargeMaxPerMonth": 5
}Auto-recharge fires when aiPrepaidBalance drops below $0.10 (not $0), so the next AI action does not briefly fail while the recharge lands.
Rate limits
Limits are set per endpoint where the work is heavy. The inbound message endpoint accepts 120 requests per minute per API token, and the AI endpoints are limited per user and per account. When you reach a limit, the API answers 429 Too Many Requests; the AI endpoints also send a Retry-After header. Wait and retry after the window resets.
Need higher limits? Contact us to discuss your requirements.
Need help?
If you have questions about the API or need help with your integration, reach out to hello@deskwoot.com or use the live chat on our website.