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

Auf dieser Seite

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.

ScopeAccess
readRead access to all resources
writeWrite access to all resources
contacts:readRead contacts only
contacts:writeCreate and update contacts
conversations:readRead conversations and messages
conversations:writeUpdate conversations and send messages
conversations:deleteDelete conversations in bulk. Granting it needs the delete permission
help_center:readRead portals, articles, and categories
help_center:writeCreate and update portals, articles, and categories
inboxes:readList the account's inboxes
settings:readRead custom attribute definitions
drafts:readRead reply drafts waiting for approval
drafts:writePropose reply drafts for an agent to approve
commerce:readLook up orders, payments and customers in connected stores
commerce:refundRefund orders. Granting it needs the refund permission
commerce:cancelCancel orders and subscriptions. Granting it needs the cancel permission
commerce:fulfillMark orders as fulfilled. Granting it needs the fulfill permission
inbound:ingestSend 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.

StatusMeaningDescription
200OKRequest succeeded
201CreatedResource created
400Bad RequestInvalid parameters or missing required fields
401UnauthorizedMissing or invalid API token
403ForbiddenToken lacks required scope or feature not available on your plan
404Not FoundResource does not exist
409ConflictResource already exists (e.g. duplicate email)
500Server ErrorUnexpected error on our side
Error response example
{
  "error": "Label with this title already exists"
}

Conversations

GET/v1/accounts/{accountId}/conversations

List 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

ParameterTypeRequiredDescription
statusstringOptionalOPEN, PENDING, RESOLVED, SNOOZED or SPAM, comma separated, or ALL for every status but SPAM (default OPEN,PENDING)
typestringOptionalconversations (default), tickets or all
category_idstringOptionalA category id (see Tickets, Categories)
ticket_status_idstringOptionalA custom ticket status id (see Tickets, Ticket statuses)
prioritystringOptionalURGENT, HIGH, MEDIUM or LOW, comma separated
assignee_idstringOptionalAn agent id, me, or unassigned
team_idstringOptionalA team id
created_bystringOptionalme: only the tickets you created (lists tickets whatever the type)
trackerstringOptionalonly: just the trackers; exclude: everything but them (see Tickets, Trackers). Trackers are tickets, so only lists something with type tickets or all
issue_keystringOptionalA 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_idstringOptionalAn inbox id
label_idstringOptionalLabel ids, comma separated
contact_idstringOptionalA contact id
attr[<key>]stringOptionalA 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)
searchstringOptionalFree text
sortstringOptionalnewest (default) or oldest
pagenumberOptionalPage number (1 to 100)
pageSizenumberOptionalRows per page (1 to 100, default 25)
cursorstringOptionalstart, then meta.nextCursor
GET/v1/conversations/{conversationId}

Retrieve a single conversation with its contact, inbox, assignee, team, labels, and messages.

Auth: Bearer token (conversations:read) or session

Response
{
  "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": [...]
}
PATCH/v1/conversations/{conversationId}

Update a conversation's status, priority, assignee, team, or custom attributes.

Auth: Bearer token (conversations:write) or session

ParameterTypeRequiredDescription
statusstringOptionalOPEN, RESOLVED, PENDING, or SNOOZED
prioritystring | nullOptionalURGENT, HIGH, MEDIUM, LOW, or null to clear
assigneeIdstring | nullOptionalAgent 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
teamIdstring | nullOptionalTeam ID to assign, or null to unassign
mutedbooleanOptionalMute notifications for this conversation
categoryIdstring | nullOptionalCategory id (see Tickets, Categories), or null to clear. Needs tickets switched on and in the plan; an archived category is refused
slaPolicyIdstring | nullOptionalThe 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
customAttributesobjectOptionalConversation 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.
Example
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}'
Set custom attributes
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}}'
POST/v1/accounts/{accountId}/conversations/bulk

Update multiple conversations at once. Supports status changes, assignments, priority, labels, and muting.

Auth: Bearer token (conversations:write) or session

ParameterTypeRequiredDescription
conversationIdsstring[]RequiredArray 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).
actionstringRequiredchange_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
valuestring | nullOptionalThe target value for the action
Example
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

GET/v1/conversations/{conversationId}/messages

List 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

Response
[
  {
    "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": []
  }
]
GET/v1/conversations/{conversationId}/history

Who 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

ParameterTypeRequiredDescription
limitnumberOptionalRows per page of each list (1 to 200, default 50)
beforestringOptionalactivity.meta.nextCursor: the next page of the lines alone
assignmentsBeforestringOptionalassignments.meta.nextCursor: the next page of the stretches alone (not together with before, 400 invalid_cursor)
POST/v1/conversations/{conversationId}/messages

Send a new message in a conversation. The message is delivered to the customer through the original channel.

Auth: Bearer token (conversations:write) or session

ParameterTypeRequiredDescription
contentstringRequiredMessage text
privatebooleanOptionalIf true, the message is only visible to agents (internal note). Default: false
messageTypestringOptionalOUTGOING (default) or INCOMING
Example
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}'
POST/v1/conversations/{conversationId}/attachments

Send 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

ParameterTypeRequiredDescription
filefileRequiredA file to attach. Repeat the field to send several files with one message (max 10).
contentstringOptionalMessage text sent with the files
privatebooleanOptionalIf true, the message is only visible to agents (internal note). Default: false
Example
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."
Refused file
// 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.

POST/v1/conversations/{conversationId}/labels

Add a label to a conversation.

Auth: Bearer token (conversations:write) or session

ParameterTypeRequiredDescription
labelIdstringRequiredThe label ID to add
DELETE/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.

POST/v1/accounts/{accountId}/inbound

Store 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)

ParameterTypeRequiredDescription
inboxIdstringRequiredAn API inbox of the workspace
externalContactIdstringRequiredYour own id for the customer; messages with the same id share one contact
contentstringRequiredThe message
contentTypestringOptionalTEXT (default) or HTML
sourceIdstringOptionalYour own id for the message, so a retry stores nothing. Without it the Idempotency-Key header or messageId serves
contactNamestringOptionalThe contact's name
contactEmailstringOptionalThe new contact's email
contactPhonestringOptionalThe new contact's phone number, up to 64 characters with digits
subjectstringOptionalThe new conversation's subject, up to 1024 characters
prioritystringOptionalURGENT, HIGH, MEDIUM or LOW for the new conversation
assigneeIdstringOptionalAn agent of the workspace who gets the new conversation, instead of auto assignment
customAttributesobjectOptionalConversation custom attributes, checked like the conversation API checks them
Example
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".

GET/v1/conversations/{conversationId}/reply-window

Whether 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

Response
{
  "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 }
  }
}
GET/v1/accounts/{accountId}/inboxes/{inboxId}/whatsapp-templates

Every 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

POST/v1/conversations/{conversationId}/messages

Send 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

ParameterTypeRequiredDescription
template.idstringOptionalThe template's id from the list above. Or pass template.name and template.language.
template.values.headerobject | string[]OptionalValues of a text header's variable, by key ({"1": "A-1001"}) or as a list.
template.values.bodyobject | string[]OptionalValues of the body's variables: by position ({"1": "Emma"} or ["Emma", "A-1001"]) or by name ({"first_name": "Emma"}).
template.values.buttonsobjectOptionalBy button index: the end of a URL button's link, or a copy code button's code.
Example
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"]}}}'
Refused template
// 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

Tickets require the Enterprise plan and the Tickets switch under Settings > Tickets. After a workspace leaves Enterprise its tickets can still be read, commented on, answered from and resolved; only creating new ones stops.

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

POST/v1/conversations/{conversationId}/tickets

Create 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)

ParameterTypeRequiredDescription
titlestringRequired1 to 200 characters
summarystringOptionalUp to 20,000 characters, stored as the ticket's first internal note
contextMessageIdsstring[]OptionalUp to 500 message ids of the conversation the ticket is made from, shown in the conversation's order
contextstringOptionalinteraction: 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
categoryIdstringOptionalA category that is not archived. Required when the workspace requires a category
prioritystring | nullOptionalURGENT, 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
teamIdstringOptionalTeam that gets the ticket
assigneeIdstringOptionalAgent who gets the ticket: an active member of the workspace
resolveSourcebooleanOptionaltrue 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.

Example
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"
  }
}
GET/v1/conversations/{conversationId}/tickets

The 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

GET/v1/conversations/{conversationId}/ticket-context

What 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

ParameterTypeRequiredDescription
limitnumberOptionalMessages per page (1 to 200, default 50)
beforestringOptionalmeta.nextCursor of the previous page
GET/v1/conversations/{ticketId}/linked-messages

The 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

ParameterTypeRequiredDescription
limitnumberOptionalNewer messages per page (1 to 200, default 50)
beforestringOptionalnewer.meta.nextCursor of the previous page
contextLimitnumberOptionalContext messages per page (at most and by default 500)
contextAfterstringOptionalcontextMeta.nextCursor: the next page of the context alone
POST/v1/conversations/{ticketId}/customer-reply

Reply 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)

ParameterTypeRequiredDescription
contentstringRequiredThe reply
contentTypestringOptionalTEXT or HTML
subjectstringOptionalEmail subject
ccstring[]OptionalEmail cc addresses
bccstring[]OptionalEmail bcc addresses
resolvebooleanOptionalAlso resolve the ticket (never the conversation)
ticketStatusIdstringOptionalWith 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.

POST/v1/conversations/{conversationId}/resolve-as-ticket

Resolve 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)

ParameterTypeRequiredDescription
categoryIdstring | nullOptionalThe 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.

POST/v1/conversations/{conversationId}/ticket-draft

Draft 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

ParameterTypeRequiredDescription
messageIdsstring[]Required1 to 500 message ids of the conversation
localestringOptionalThe 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.

GET/v1/accounts/{accountId}/ticket-categories

The workspace's categories in their order, archived ones included: id, name, position, archived, archivedAt.

Auth: Bearer token (conversations:read) or session

POST/v1/accounts/{accountId}/ticket-categories

Add 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

ParameterTypeRequiredDescription
namestringRequired1 to 80 characters
PATCH/v1/accounts/{accountId}/ticket-categories/{categoryId}

Rename, move or archive a category.

Auth: Session (permission: settings.manage_ticket_categories), Enterprise plan

ParameterTypeRequiredDescription
namestringOptional1 to 80 characters
positionnumberOptionalThe index in the order (archived ones included) it moves to
archivedbooleanOptionalArchive or unarchive
DELETE/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.

GET/v1/accounts/{accountId}/ticket-statuses

The workspace's ticket statuses in their order, archived ones included: id, name, baseStatus, pausesSla, position, archived, archivedAt.

Auth: Bearer token (conversations:read) or session

POST/v1/accounts/{accountId}/ticket-statuses

Add 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

ParameterTypeRequiredDescription
namestringRequired1 to 40 characters
baseStatusstringRequiredOPEN, PENDING or RESOLVED
pausesSlabooleanOptionalOnly under OPEN
PATCH/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

DELETE/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).

POST/v1/accounts/{accountId}/trackers

Create 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)

ParameterTypeRequiredDescription
titlestringRequired1 to 200 characters
summarystringOptionalUp to 20,000 characters, stored as the first internal note
categoryIdstringOptionalRequired when the workspace requires a category; not archived
prioritystring | nullOptionalURGENT, HIGH, MEDIUM or LOW. Left out, the workspace's default priority for new tickets; null means none
teamIdstringOptionalTeam that gets the tracker
assigneeIdstringOptionalAgent 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.

GET/v1/conversations/{trackerId}/tracker-links

What 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

ParameterTypeRequiredDescription
limitnumberOptionalRows per page (1 to 100, default 100)
beforestringOptionalmeta.nextCursor of the previous page (400 when it is no cursor)
POST/v1/conversations/{trackerId}/tracker-links

Link 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)

ParameterTypeRequiredDescription
conversationIdsstring[]Required1 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.

DELETE/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)

POST/v1/conversations/{trackerId}/linked-update

Update 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

ParameterTypeRequiredDescription
contentstringOptionalThe update
contentTypestringOptionalTEXT or HTML
subjectstringOptionalEmail subject
resolveLinkedbooleanOptionalAlso resolve the linked conversations and tickets
resolveTrackerbooleanOptionalAlso resolve the tracker, last
dryRunbooleanOptionalAnswer 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.

GET/v1/conversations/{ticketId}/linked-issues

A 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

POST/v1/conversations/{ticketId}/linked-issues

Link 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)

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

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

ToolWhat it does
tickets_listLists tickets, filtered by status, custom ticket status, category, priority, assignee, team, contact, custom attributes, a linked Jira issue's key or search (read)
tickets_getOne ticket with its thread, its linked conversation context and its linked Jira issues (read)
ticket_categories_listThe categories (read)
ticket_statuses_listThe custom ticket statuses (read)
tickets_createA 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_commentAn internal comment on a ticket (internal)
tickets_updateStatus, custom ticket status, assignee, team, category, priority or title (internal)
tickets_reply_to_customerA 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_createA tracker, with the workspace's default priority unless one is given (null for none) (internal)
trackers_linkLinks 1 to 100 conversations or tickets to a tracker, one result per id (internal)
trackers_unlinkUnlinks one conversation or ticket from a tracker (internal)
trackers_linked_listWhat is linked to a tracker, with each customer and the tracker's last update for it (read)
tickets_link_issueLinks an existing Jira issue to a ticket by its key (internal)
tickets_unlink_issueUnlinks 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

GET/v1/accounts/{accountId}/contacts

List contacts with optional search and segment filtering. Supports pagination.

Auth: Bearer token (contacts:read) or session

ParameterTypeRequiredDescription
identifierstringOptionalExact-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.
telegramIdstringOptionalExact-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.
searchstringOptionalSearch 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.
segmentstringOptionalFilter: active-7d, active-30d, no-email, has-email, has-phone, new
pagenumberOptionalPage number (default: 1)
pageSizenumberOptionalItems per page (default: 25)
Response
{
  "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.
GET/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

POST/v1/accounts/{accountId}/contacts

Create 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)

ParameterTypeRequiredDescription
namestringOptionalThe contact's name
emailstringOptionalUnique in the workspace
phonestringOptionalA phone number with digits
companystringOptionalFree text
identifierstringOptionalYour own id for the contact, unique in the workspace; find it again with GET ?identifier=
organizationIdstringOptionalAn organization of the workspace (see Organizations)
telegramIdstringOptionalThe 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
customAttributesobjectOptionalContact custom attributes
PATCH/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)

POST/v1/accounts/{accountId}/contacts/merge

Merge 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

ParameterTypeRequiredDescription
primaryContactIdstringRequiredThe contact to keep
secondaryContactIdstringRequiredThe 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".

GET/v1/accounts/{accountId}/organizations

List 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

ParameterTypeRequiredDescription
searchstringOptionalMatches the name or the domain
pagenumberOptionalPage number (default: 1)
pageSizenumberOptionalItems per page (default: 25, at most 100)
Response
{
  "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
}
POST/v1/accounts/{accountId}/organizations

Create 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)

ParameterTypeRequiredDescription
namestringRequiredUnique in the workspace, at most 255 characters
domainstringOptionalA domain such as acme.com, without https:// or a path; stored in lower case
websitestringOptionalAn http or https link, or a bare host such as acme.com
phonestringOptionalA phone number with digits
industrystringOptionalFree text
descriptionstringOptionalFree text
notesstringOptionalInternal notes for your team
GET/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

PATCH/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)

DELETE/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)

POST/v1/accounts/{accountId}/contacts/{contactId}/group-organization

Make 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)

ParameterTypeRequiredDescription
namestringOptionalThe new organization's name, unique in the workspace
organizationIdstringOptionalAn organization of the workspace, instead of name
Response
{
  "organization": { "id": "clx3org...", "name": "Acme GmbH" },
  "created": true,
  "linked": 4
}

Inboxes

GET/v1/accounts/{accountId}/inboxes

List all inboxes in your account.

Auth: Bearer token (read) or session

Response
[
  {
    "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"
  }
]
POST/v1/accounts/{accountId}/inboxes

Create a new inbox. Supported channel types: WEB, EMAIL, TELEGRAM, WHATSAPP, SMS, LINE, TWITTER.

Auth: Session (permission: settings.manage_inboxes)

ParameterTypeRequiredDescription
namestringRequiredDisplay name for the inbox
channelTypestringRequiredWEB, EMAIL, TELEGRAM, WHATSAPP, SMS, LINE, or TWITTER
greetingstringOptionalWelcome message shown to visitors
greetingEnabledbooleanOptionalShow greeting message (default: false)
enableAutoAssignbooleanOptionalAuto-assign to available agents (default: true)
botEnabledbooleanOptionalEnable AI bot for this inbox
agentBotIdstringOptionalBot ID to use when botEnabled is true
Example: Create Telegram inbox
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..."
  }'
GET/v1/accounts/{accountId}/inboxes/{inboxId}

Get full details for a single inbox.

Auth: Bearer token (read) or session

PATCH/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)

CSAT-related fields
{
  "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.

DELETE/v1/accounts/{accountId}/inboxes/{inboxId}

Delete an inbox and all associated data. This action cannot be undone.

Auth: Session (permission: settings.manage_inboxes)

Agents

GET/v1/accounts/{accountId}/agents

List all agents (team members) in your account, including their role and custom role.

Auth: Bearer token (read) or session

Response
{
  "agents": [
    {
      "user": {
        "id": "clx_user1",
        "name": "Anna",
        "email": "anna@example.com",
        "avatarUrl": null
      },
      "role": "ADMINISTRATOR",
      "customRole": null
    }
  ],
  "maxAgents": 10,
  "plan": "ENTERPRISE"
}
POST/v1/accounts/{accountId}/agents

Invite a new agent to your account. An invitation email is sent automatically.

Auth: Session (admin only)

ParameterTypeRequiredDescription
namestringRequiredFull name of the new agent
emailstringRequiredEmail address
rolestringOptionalAGENT (default) or ADMINISTRATOR
customRoleIdstringOptionalID of a custom role to assign
PATCH/v1/accounts/{accountId}/agents/{agentId}

Update an agent's role.

Auth: Session (admin only)

ParameterTypeRequiredDescription
rolestringOptionalAGENT or ADMINISTRATOR
customRoleIdstring | nullOptionalCustom role ID or null to remove
DELETE/v1/accounts/{accountId}/agents/{agentId}

Remove an agent from your account.

Auth: Session (admin only)

Teams

Teams require the Business plan or higher.
GET/v1/accounts/{accountId}/teams

List all teams with their member count.

Auth: Bearer token (read) or session

POST/v1/accounts/{accountId}/teams

Create a new team.

Auth: Session (permission: settings.manage_teams)

ParameterTypeRequiredDescription
namestringRequiredTeam name
descriptionstringOptionalTeam description
allowAutoAssignbooleanOptionalAllow auto-assignment to this team (default: true)
GET/v1/accounts/{accountId}/teams/{teamId}

Get a team with its full member list.

Auth: Bearer token (read) or session

PATCH/v1/accounts/{accountId}/teams/{teamId}

Update team name, description, or auto-assign setting.

Auth: Session (permission: settings.manage_teams)

DELETE/v1/accounts/{accountId}/teams/{teamId}

Delete a team.

Auth: Session (permission: settings.manage_teams)

POST/v1/accounts/{accountId}/teams/{teamId}/members

Add an agent to a team.

Auth: Session (permission: settings.manage_teams)

ParameterTypeRequiredDescription
userIdstringRequiredThe agent's user ID
DELETE/v1/accounts/{accountId}/teams/{teamId}/members

Remove an agent from a team.

Auth: Session (permission: settings.manage_teams)

ParameterTypeRequiredDescription
userIdstringRequiredThe agent's user ID

Labels

GET/v1/accounts/{accountId}/labels

List all labels in your account.

Auth: Bearer token (read) or session

Response
[
  {
    "id": "clx_label1",
    "title": "VIP",
    "color": "#6366F1",
    "description": "High-value customers",
    "showOnSidebar": true
  }
]
POST/v1/accounts/{accountId}/labels

Create a new label.

Auth: Session (permission: settings.manage_labels)

ParameterTypeRequiredDescription
titlestringRequiredLabel name
colorstringOptionalHex color code (default: "#6366F1")
descriptionstringOptionalLabel description
showOnSidebarbooleanOptionalShow in sidebar filter (default: true)
PATCH/v1/accounts/{accountId}/labels/{labelId}

Update a label's title, color, description, or visibility.

Auth: Session (permission: settings.manage_labels)

DELETE/v1/accounts/{accountId}/labels/{labelId}

Delete a label. It will be removed from all conversations.

Auth: Session (permission: settings.manage_labels)

Canned Responses

GET/v1/accounts/{accountId}/canned-responses

List all canned responses.

Auth: Bearer token (read) or session

POST/v1/accounts/{accountId}/canned-responses

Create a canned response. Agents can use it by typing the short code in the message input.

Auth: Session (permission: settings.manage_canned_responses)

ParameterTypeRequiredDescription
shortCodestringRequiredTrigger shortcode (e.g. 'greeting')
contentstringRequiredResponse 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.
POST/v1/accounts/{accountId}/canned-responses/{cannedResponseId}/render

Expand 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

ParameterTypeRequiredDescription
conversationIdstringRequiredConversation whose contact, inbox and account fill the variables
PATCH/v1/accounts/{accountId}/canned-responses/{cannedResponseId}

Update a canned response.

Auth: Session (permission: settings.manage_canned_responses)

DELETE/v1/accounts/{accountId}/canned-responses/{cannedResponseId}

Delete a canned response.

Auth: Session (permission: settings.manage_canned_responses)

Macros

GET/v1/accounts/{accountId}/macros

List all public macros and your personal macros.

Auth: Bearer token (read) or session

Response
{
  "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." }
      ]
    }
  ]
}
POST/v1/accounts/{accountId}/macros

Create a new macro with one or more actions.

Auth: Bearer token (write) or session

ParameterTypeRequiredDescription
namestringRequiredMacro name
actionsarrayOptionalArray of action objects with type and value
visibilitystringOptionalPERSONAL (default) or PUBLIC
PATCH/v1/accounts/{accountId}/macros/{macroId}

Update a macro.

Auth: Bearer token (write) or session

DELETE/v1/accounts/{accountId}/macros/{macroId}

Delete a macro.

Auth: Bearer token (write) or session

Automation Rules

Automation rules require the Business plan or higher.
GET/v1/accounts/{accountId}/automation-rules

List all automation rules.

Auth: Bearer token (read) or session

POST/v1/accounts/{accountId}/automation-rules

Create an automation rule with conditions and actions.

Auth: Session (permission: settings.manage_automation)

ParameterTypeRequiredDescription
namestringRequiredRule name
eventNamestringRequiredTrigger 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)
conditionsarrayOptionalArray 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
actionsarrayOptionalArray of action objects (type, value)
activebooleanOptionalEnable 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.

Example
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" }
    ]
  }'
A rule for tickets: Billing goes to team Accounts
{
  "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" }
  ]
}
PATCH/v1/accounts/{accountId}/automation-rules/{ruleId}

Update an automation rule.

Auth: Session (permission: settings.manage_automation)

DELETE/v1/accounts/{accountId}/automation-rules/{ruleId}

Delete an automation rule.

Auth: Session (permission: settings.manage_automation)

Campaigns

Campaigns require the Business plan or higher.
GET/v1/accounts/{accountId}/campaigns

List all campaigns (ongoing and one-off).

Auth: Bearer token (read) or session

POST/v1/accounts/{accountId}/campaigns

Create a new campaign.

Auth: Session (permission: settings.manage_campaigns)

ParameterTypeRequiredDescription
titlestringRequiredCampaign title
messagestringRequiredMessage content
campaignTypestringOptionalONGOING or ONE_OFF (default: ONGOING)
inboxIdstringOptionalInbox to send from
scheduledAtstringOptionalISO 8601 date and time with Z or an offset, for example 2026-10-02T14:30:00Z, for one-off campaigns
audienceobjectOptionalAudience targeting rules
GET/v1/accounts/{accountId}/campaigns/{campaignId}

Get campaign details.

Auth: Bearer token (read) or session

PATCH/v1/accounts/{accountId}/campaigns/{campaignId}

Update a campaign.

Auth: Session (permission: settings.manage_campaigns)

POST/v1/accounts/{accountId}/campaigns/{campaignId}/send

Send a one-off campaign immediately.

Auth: Session (permission: settings.manage_campaigns)

DELETE/v1/accounts/{accountId}/campaigns/{campaignId}

Delete a campaign.

Auth: Session (permission: settings.manage_campaigns)

Webhooks

Webhooks require the Startup plan or higher. On the free Hacker plan, listing and deleting webhooks still work, and existing webhooks send nothing until the plan includes them again.

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.

FieldTypeDescription
X-Deskwoot-EventheaderThe event name, for example message.created
X-Deskwoot-Signatureheadersha256=<hex HMAC of the raw body>, signed with the webhook secret
eventstringEvent name, same value as the X-Deskwoot-Event header
dataobjectEvent specific payload, see the event list below
timestampstringISO 8601 time the event was dispatched
accountIdstringThe account the event belongs to
Request
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.

Verification example (Node.js)
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.

EventSent when
conversation.createdA new conversation was started: a customer wrote in on any channel, or it was created through the API
conversation.updatedA conversation's status, assignee, team, priority or other attributes changed
conversation.assignedThe assignee of a conversation changed (also sent when it was unassigned)
conversation.resolvedA conversation was marked as resolved
message.createdA message was sent or received in a conversation, including approved bot drafts
contact.createdA new contact was created
contact.updatedA contact's details were updated
csat.submittedA customer submitted a satisfaction rating
ticket.createdA ticket was created from a conversation, or an interaction was filed as a resolved ticket (which also sends ticket.resolved)
ticket.updatedA 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.assignedThe assignee of a ticket changed (also sent when it was unassigned)
ticket.resolvedA 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.created
{
  "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" }
}
conversation.updated
{
  "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
conversation.assigned
{
  "conversationId": "clx_conv1",
  "assigneeId": "clx_user2",
  "previousAssigneeId": "clx_user1"
}
// assigneeId is null when the conversation was unassigned
conversation.resolved
{
  "conversationId": "clx_conv1",
  "resolvedBy": "clx_user2"
}
message.created
{
  "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"
  }
}
contact.created
{
  "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.updated
{
  "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
ticket.created, ticket.updated, ticket.assigned, ticket.resolved
{
  "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
csat.submitted
{
  "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
GET/v1/accounts/{accountId}/webhooks

List all webhooks. Secrets are redacted to their first 4 characters.

Auth: Session or API token (scope: read), account member

POST/v1/accounts/{accountId}/webhooks

Create a webhook. A signing secret is generated automatically and returned once in the response.

Auth: Session or API token (scope: write), administrator role

ParameterTypeRequiredDescription
urlstringRequiredThe HTTPS URL to receive webhook events
subscriptionsstring[]OptionalDotted 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.
Example
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"]
  }'
Response
{
  "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"
}
PATCH/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)

DELETE/v1/accounts/{accountId}/webhooks/{webhookId}

Delete a webhook.

Auth: Session (administrator role)

API Tokens

API tokens require the Enterprise plan.
GET/v1/accounts/{accountId}/api-tokens

List all API tokens. The actual token value is not included for security.

Auth: Session (admin only)

Response
[
  {
    "id": "clx_tk1",
    "name": "Production Integration",
    "scopes": ["read", "write"],
    "lastUsedAt": "2026-03-14T10:00:00Z",
    "expiresAt": null,
    "createdAt": "2026-02-01T00:00:00Z"
  }
]
POST/v1/accounts/{accountId}/api-tokens

Create a new API token. The token value is only shown once in the response.

Auth: Session (admin only)

ParameterTypeRequiredDescription
namestringOptionalA descriptive name for the token
scopesstring[]OptionalArray of scopes (default: ["read", "write"])
expiresAtstringOptionalISO date when the token expires (optional, never expires if omitted)
DELETE/v1/accounts/{accountId}/api-tokens/{tokenId}

Revoke an API token. It will immediately stop working.

Auth: Session (admin only)

Bots

GET/v1/accounts/{accountId}/bots

List all AI bots. API keys are masked in the response.

Auth: Bearer token (read) or session

POST/v1/accounts/{accountId}/bots

Create a new AI bot.

Auth: Session (permission: bots.manage)

ParameterTypeRequiredDescription
namestringRequiredBot name
aiProviderstringOptionalAI provider: openai, anthropic, google, groq, or custom
aiApiKeystringOptionalYour AI provider API key
aiModelstringOptionalModel name (e.g. gpt-4o, claude-sonnet-4-20250514)
systemPromptstringOptionalCustom instructions for the bot
useHelpCenterbooleanOptionalLet the bot use your help center articles as context
temperaturenumberOptionalCreativity level 0-1 (default: 0.3)
maxTokensnumberOptionalMax response length 100-2000 (default: 500)
PATCH/v1/accounts/{accountId}/bots/{botId}

Update a bot's settings.

Auth: Session (permission: bots.manage)

DELETE/v1/accounts/{accountId}/bots/{botId}

Delete a bot.

Auth: Session (permission: bots.manage)

POST/v1/accounts/{accountId}/bots/test

Test a bot's response with a sample message.

Auth: Session (permission: bots.manage)

ParameterTypeRequiredDescription
botIdstringRequiredBot ID to test
messagestringRequiredTest message

Custom Attributes

Custom attributes require the Business plan or higher.

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.

ParameterTypeRequiredDescription
TEXTstringOptionalAny text up to 1,000 characters
NUMBERnumberOptionalA number; numeric strings like "42" are accepted
LINKstringOptionalA full http or https URL
DATEstringOptionalA calendar day as YYYY-MM-DD
LISTstringOptionalOne of the definition's attributeValues
CHECKBOXbooleanOptionaltrue 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).

GET/v1/accounts/{accountId}/custom-attributes

List custom attribute definitions. Use ?model=CONVERSATION or ?model=CONTACT to filter.

Auth: Bearer token (conversations:read or contacts:read) or session

POST/v1/accounts/{accountId}/custom-attributes

Create a custom attribute definition.

Auth: Session (admin only)

ParameterTypeRequiredDescription
attributeDisplayNamestringRequiredDisplay name shown in the UI
attributeKeystringRequiredUnique key used in API (snake_case)
attributeDisplayTypestringOptionalTEXT, NUMBER, LINK, DATE, LIST, or CHECKBOX (default: TEXT)
attributeModelstringOptionalCONVERSATION or CONTACT (default: CONVERSATION)
attributeValuesstring[]OptionalThe options of a LIST attribute (required for LIST, at most 100)
PATCH/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)

DELETE/v1/accounts/{accountId}/custom-attributes/{attributeId}

Delete a custom attribute.

Auth: Session (admin only)

Custom Roles

GET/v1/accounts/{accountId}/custom-roles

List all custom roles with their permissions.

Auth: Session (admin only)

POST/v1/accounts/{accountId}/custom-roles

Create a custom role with granular permissions.

Auth: Session (admin only)

ParameterTypeRequiredDescription
namestringRequiredRole name
descriptionstringOptionalRole description
permissionsobjectOptionalPermission flags (e.g. conversations.manage, settings.manage_inboxes)
PATCH/v1/accounts/{accountId}/custom-roles/{roleId}

Update a custom role.

Auth: Session (admin only)

DELETE/v1/accounts/{accountId}/custom-roles/{roleId}

Delete a custom role.

Auth: Session (admin only)

Reports

Every number of the Reports page, and the rows behind it, for your own dashboards and BI tools. API tokens need the Enterprise plan and the 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 for first_response_* in the breakdown CSV, firstReplyAt and firstResponseSeconds in 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. unanswered follows the same rule: it counts only conversations where the customer asked something and nobody has answered.
  • agent_id now 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.

ParameterTypeRequiredDescription
fromstringOptionalStart of the period: a date (YYYY-MM-DD, start of that day in timezone) or an ISO 8601 timestamp
tostringOptionalEnd of the period: a date (inclusive, the whole day) or an ISO 8601 timestamp (exclusive). Default: now
daysnumberOptionalInstead of from/to: the last N days up to now (1 to 366, default 30)
timezonestringOptionalIANA timezone for dates and day/week buckets, e.g. Europe/Berlin (default UTC)
agent_idstringOptionalAgent 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_idstringOptionalTeam ids; none matches conversations without a team
inbox_idstringOptionalInbox ids
channelstringOptionalWEBSITE, EMAIL, WHATSAPP, WHATSAPP_TWILIO, TELEGRAM, TWITTER, SMS, LINE, API
label_idstringOptionalLabel ids (conversations with any of them); none matches conversations without a label
statusstringOptionalOPEN, PENDING, RESOLVED, SNOOZED
prioritystringOptionalurgent, high, medium, low; none matches conversations without a priority
typestringOptionalconversations (default), tickets or all, on summary, breakdown, conversations, replies and csat. Without it they count customer conversations only
category_idstringOptionalCategory ids of conversations or tickets; none matches the ones without a category
ticket_status_idstringOptionalCustom ticket status ids; none matches tickets in a plain base status and every conversation
hoursstringOptionalcalendar (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.

GET/v1/accounts/{accountId}/reports/summary

All headline numbers for the period and filters, plus the number of matching conversations of all time.

Auth: Bearer token (conversations:read) or session

ParameterTypeRequiredDescription
metricsstringOptionalcore, 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
Example
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
}
Agent scoped (agent_id): the agent's own times
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 }
  },
  ...
}
GET/v1/accounts/{accountId}/reports/breakdown

The 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

ParameterTypeRequiredDescription
group_bystringRequiredagent, team, inbox, channel, label, status, priority, category, day, week or ticket_status (the custom ticket status; with type=tickets the volume per queue)
formatstringOptionalcsv for one row per group
GET/v1/accounts/{accountId}/reports/conversations

One 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

ParameterTypeRequiredDescription
slicestringOptionalall (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)
sortstringOptionalcreated_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)
limitnumberOptionalRows per page (1 to 500, default 100)
cursorstringOptionalnextCursor of the previous page, in the same sort
formatstringOptionalcsv for every row of the period in one file
The conversations behind a number: first responses over an hour
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
}
GET/v1/accounts/{accountId}/reports/replies

One 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

ParameterTypeRequiredDescription
limitnumberOptionalRows per page (1 to 500, default 100)
cursorstringOptionalnextCursor of the previous page
formatstringOptionalcsv for every reply of the period in one file
GET/v1/accounts/{accountId}/reports/csat

CSAT 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

ParameterTypeRequiredDescription
respondedbooleanOptionaltrue keeps only rated surveys
ratingnumberOptional1 to 5 keeps only the surveys rated so, the ones that star counts in the distribution
limitnumberOptionalSurveys per page (1 to 500, default 100)
cursorstringOptionalnextCursor of the previous page
formatstringOptionalcsv for every survey of the period in one file
GET/v1/accounts/{accountId}/reports/article-feedback

Help 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

GET/v1/accounts/{accountId}/reports/export

The 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

SLA policies require the Business plan or higher.
GET/v1/accounts/{accountId}/sla-policies

List all SLA policies with conversation counts.

Auth: Bearer token (read) or session

POST/v1/accounts/{accountId}/sla-policies

Create an SLA policy.

Auth: Session (admin only)

ParameterTypeRequiredDescription
namestringRequiredPolicy name
descriptionstringOptionalPolicy description
firstResponseTimeThresholdnumberOptionalMax seconds for first response
nextResponseTimeThresholdnumberOptionalMax seconds between responses
resolutionTimeThresholdnumberOptionalMax seconds to resolve
onlyDuringBusinessHoursbooleanOptionalOnly count time during business hours (default: false)
priorityTargetsobjectOptionalOwn 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.
businessHoursScheduleIdstringOptionalA 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.
PATCH/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)

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

GET/v1/accounts/{accountId}/business-hours-schedules

The 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)

POST/v1/accounts/{accountId}/business-hours-schedules

Add 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

ParameterTypeRequiredDescription
namestringRequired1 to 255 characters
timezonestringRequiredA time zone like Europe/Berlin
schedulearrayRequiredThe 7 days of the week, each like { "day": "Monday", "enabled": true, "allDay": false, "openTime": "09:00", "closeTime": "17:00" }
PATCH/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

DELETE/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

GET/v1/accounts/{accountId}/business-hours

Get your account's business hours configuration.

Auth: Bearer token (read) or session

Response
{
  "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 }
  ]
}
PUT/v1/accounts/{accountId}/business-hours

Set business hours. Provide the full schedule.

Auth: Session (permission: settings.manage_account)

Help Center (Portals)

GET/v1/accounts/{accountId}/portals

List your help center portals with article and category counts.

Auth: Bearer token (help_center:read) or session

POST/v1/accounts/{accountId}/portals

Create a new help center portal.

Auth: Bearer token (help_center:write) or session

ParameterTypeRequiredDescription
namestringRequiredPortal name
slugstringRequiredURL slug (e.g. 'help')
headerTextstringOptionalHeader text shown on the portal
brandColorstringOptionalBrand color hex (default: "#2563EB")
customDomainstringOptionalCustom domain for the portal
PATCH/v1/accounts/{accountId}/portals/{portalId}

Update portal settings, including which languages it offers translations in.

Auth: Bearer token (help_center:write) or session

ParameterTypeRequiredDescription
namestringOptionalPortal name
pageTitlestringOptionalLarge heading shown to visitors (defaults to the name)
headerTextstringOptionalSubtitle shown under the title
brandColorstringOptionalBrand color hex
faviconUrlstringOptionalAbsolute 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.
showChatWidgetbooleanOptionalShow 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.
articleSidebarbooleanOptionalArticle 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.
homePageLinkstringOptionalURL the portal name / logo links back to (usually your main website).
topPillLabelstringOptionalSmall pill shown above the headline (announcement / tagline). Empty hides the pill.
topPillBgstringOptionalBackground color hex for the top pill.
topPillTextstringOptionalText color hex for the top pill.
searchEnabledbooleanOptionalShow the search bar on the home page (default true).
searchPlaceholderstringOptionalPlaceholder text for the search input.
quickJumpEnabledbooleanOptionalShow the quick-jump link row under the search bar.
quickJumpItemsobject[]OptionalQuick-jump links: [{ label, url }]. Sanitized on save. Send null to clear.
faqEnabledbooleanOptionalShow the FAQ accordion on the home page.
faqItemsobject[]OptionalFAQ 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.
promoBannerEnabledbooleanOptionalShow the promo banner.
promoBannerobjectOptionalPromo banner content: { headline, body, ctaLabel, ctaUrl }. body keeps a curated rich-text allow-list. Send null to clear.
enabledLocalesstring[]OptionalLanguages 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.
localeControlledExternallybooleanOptionalWhen 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.
categoryCardLayoutbooleanOptionaltrue renders categories as a Browse-by-Topic card grid (each card links to /category/<slug>); false (default) keeps the inline article list.
featuredArticleIdstringOptionalId 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.
fontFamilystringOptionalWeb 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.
latestGuidesEnabledbooleanOptionalAdds a “Latest Guides” grid of recent article cards (cover image, category, title, excerpt, author, date) below the categories.
heroAlignmentstringOptional"left" or "center" (default) for the title / subtitle / search bar.
themestringOptional"light" (default), "dark", or "auto" (follows the visitor’s prefers-color-scheme). Applies on every public page.
themeTokensobjectOptionalOptional 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.
sectionOrderstring[]OptionalOrder 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"].
customCssstringOptionalScoped 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.
whiteLabelbooleanOptionalEnterprise. Removes the “Powered by Deskwoot” credit from the footer. 402 if the plan is below Enterprise.
customFooterEnabledbooleanOptionalReplace the default footer with a custom one (columns/bottom text below, or customFooterHtml).
customFooterColumnsobject[]OptionalUp to 4 footer link columns: [{ title, links: [{ label, url }] }] (max 8 links per column). Sanitized on save. Send null to clear.
customFooterBottomTextstringOptionalA single copyright / legal line rendered below the footer columns. Max 500 characters. Send null to clear.
customFooterHtmlstringOptionalRaw 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.
customHeaderHtmlstringOptionalRaw 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).
publishedbooleanOptionaltrue = live, false = draft (public URL 404s)
GET/v1/accounts/{accountId}/portals/{portalId}/articles

List articles in a portal.

Auth: Bearer token (help_center:read) or session

POST/v1/accounts/{accountId}/portals/{portalId}/articles

Create a new help center article.

Auth: Bearer token (help_center:write) or session

ParameterTypeRequiredDescription
titlestringRequiredArticle title (used for the H1, page title and SEO title)
navTitlestringOptionalOptional 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.
contentstringRequiredArticle body (HTML; <details>/<summary> are allowed for FAQ accordions)
slugstringOptionalURL slug (auto-generated from title if omitted)
categoryIdstringRequiredCategory the article belongs to (create one via the categories endpoint first)
statusstringOptionalDRAFT, PUBLISHED, or ARCHIVED (case-insensitive, default: DRAFT)
translationsobjectOptionalPer-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.
PATCH/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

DELETE/v1/accounts/{accountId}/portals/{portalId}/articles/{articleId}

Delete an article.

Auth: Bearer token (help_center:write) or session

GET/v1/accounts/{accountId}/portals/{portalId}/categories

List categories in a portal, ordered by position.

Auth: Bearer token (help_center:read) or session

POST/v1/accounts/{accountId}/portals/{portalId}/categories

Create a category. Every article must belong to a category, so create one before creating articles.

Auth: Bearer token (help_center:write) or session

ParameterTypeRequiredDescription
namestringRequiredCategory name
slugstringRequiredURL slug (e.g. 'getting-started')
descriptionstringOptionalShort description shown under the category title
localestringOptionalLanguage code (default: "en")
iconstringOptionalIcon 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.
PATCH/v1/accounts/{accountId}/portals/{portalId}/categories/{categoryId}

Update a category, including who may read it.

Auth: Bearer token (help_center:write) or session

ParameterTypeRequiredDescription
namestringOptionalCategory name
slugstringOptionalURL slug. 409 with code slug_taken when another category of the same language uses it.
descriptionstringOptionalShort description shown under the category title
iconstringOptionalIcon key shown on the category card (see create)
visibilitystringOptionalPUBLIC 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.
confirmPublicbooleanOptionalMust 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.

GET/v1/public/portals/{portalSlug}

Get public portal info with categories and article count.

Auth: None

GET/v1/public/portals/{portalSlug}/articles

List published articles. Supports search and pagination.

Auth: None

ParameterTypeRequiredDescription
categoryIdstringOptionalFilter by category
searchstringOptionalSearch articles by title or content
pagenumberOptionalPage number (default: 1)
limitnumberOptionalItems per page (default: 20, max: 50)
localestringOptionalLanguage 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.
GET/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

ParameterTypeRequiredDescription
localestringOptionalLanguage 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

GET/v1/accounts/{accountId}/settings

Get account settings including name, locale, timezone, plan, and feature flags.

Auth: Bearer token (read) or session

Response
{
  "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"
}
PATCH/v1/accounts/{accountId}/settings

Update account settings.

Auth: Session (permission: settings.manage_account)

ParameterTypeRequiredDescription
namestringOptionalAccount name
localestringOptionalDefault locale (e.g. en, de)
timezonestringOptionalIANA timezone (e.g. Europe/Berlin)
autoResolveEnabledbooleanOptionalAuto-resolve inactive conversations
autoResolveDurationnumberOptionalHours of inactivity before auto-resolve
brandColorstringOptionalBrand color of the chat widget: #rgb or #rrggbb, stored as #rrggbb in lower case. A color with transparency is refused
liveTranslateEnabledbooleanOptionalEnable live translation
liveTranslateTargetLangstringOptionalTarget language for translation

Notifications

GET/v1/accounts/{accountId}/notifications

Get paginated notifications for the current user.

Auth: Session only

Response
{
  "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
  }
}
PATCH/v1/accounts/{accountId}/notifications

Mark notifications as read.

Auth: Session only

ParameterTypeRequiredDescription
markAllReadbooleanOptionalSet to true to mark all as read
notificationIdstringOptionalOr mark a single notification as read

Audit Logs

Audit logs require the Enterprise plan.
GET/v1/accounts/{accountId}/audit-logs

Get paginated audit logs for your account. Admin only.

Auth: Session (admin only)

ParameterTypeRequiredDescription
pagenumberOptionalPage number (default: 1)
Response
{
  "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

GET/v1/accounts/{accountId}/billing

Get billing information for your account.

Auth: Session (permission: settings.manage_billing)

Response
{
  "plan": "ENTERPRISE",
  "status": "ACTIVE",
  "agentSeats": 10,
  "activeAgents": 4,
  "trialEndsAt": null,
  "currentPeriodEnd": "2026-04-01T00:00:00Z"
}
GET/v1/accounts/{accountId}/ai-billing

Get 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)

Response
{
  "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.

PATCH/v1/accounts/{accountId}/ai-billing

Update 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)

Request
{
  "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.

Interaktive Deskwoot-Demo