DelightChat API

Version 1.0.0. Download openapi.yaml

The DelightChat API reads tickets, messages and marketing numbers of one account, and makes a small set of ticket changes. The same operations are the tools of the DelightChat MCP server for AI apps: operationId is the tool name and the description is the tool text.

Authentication: send Authorization: Bearer <key or token>. An API key (dcak_...) is created by an account admin in Settings > API Keys & MCP. AI apps connect with OAuth; an MCP client that can only send a fixed header may send an API key the same way. Every credential has an admin owner and sees what that admin may see: channels outside the admin's access are never returned.

Errors: every error has the body {"error": {"code", "message", "retry_after"?, "details"?}}. Branch on code. Codes and HTTP status: invalid_key 401 (wrong, unknown or revoked), key_expired 401, account_not_available 403, owner_not_admin 403, missing_scope 403, not_found 404 (an item you may not see gives the same answer as one that does not exist), limit_reached 429 (with Retry-After), validation_error 400 (details.field names the parameter), temporarily_unavailable 503, internal_error 500. In MCP a tool failure returns the same object as a tool result with isError true; only authentication failures use HTTP 401.

Limits: each call costs points, 1 plus the most rows it can return, taken from a bucket per account. Every successful response carries X-RateLimit-Cost, X-RateLimit-Remaining and X-RateLimit-Reset (seconds until the bucket is full). A refusal is 429 limit_reached with a Retry-After header. There are also daily volume caps, a limit on concurrent requests, and an hourly cap on ticket changes per key. whoami shows the numbers for your account.

Pagination: lists use opaque cursors. Pass next_cursor as cursor. null means the last page. There are no page numbers and no totals.

Tickets: a ticket is a customer conversation. Wherever a ticket is expected, send the ticket number (123) or an R ref of the ticket (R456, the number in the ticket header; older R refs of the same ticket also work).

Dates and time: dates are YYYY-MM-DD and the end day is included in full. They are read in the timezone you pass, or in the shared timezone of your brands. Times in responses are UTC with a Z suffix.

Currency: marketing money is returned as stored with its currency beside it. Revenue is in the store currency. WhatsApp fees are in USD. Nothing is converted.

Tickets

Read tickets, messages, counts and search. Scope conversations:read.

GET /api/v1/tickets

List tickets (list_tickets)

List tickets, most recent activity first, at most 50 per call. Use it to browse or monitor tickets. Filters: status (open, assigned or closed; default is open and assigned), channel_ids (from list_channels), assignee_id (from list_agents), needs_reply (open or assigned tickets that are unread and where the customer sent the last message; it looks back 15 days unless you set active_within_days, maximum 30), tag_id (from list_tags; it needs active_within_days of 7 or less, or a start and end window of 7 days or less). Time range: send active_within_days (1 to 30), or send start and end together (dates, YYYY-MM-DD, the end day is included, at most 30 days, read in the timezone). Never send both kinds. The response has items and next_cursor. Pass next_cursor back as cursor to get the next page. A null next_cursor means the last page. There are no page numbers and no totals. A page can hold fewer than limit items on accounts with many channels; keep paging with next_cursor until it is null. Use get_counts for open-ticket counts. A channel id that is not one of your channels gives not_found. Cost: 51 points (1 plus the page size of 50).

Scope: conversations:read

Parameters

NameInTypeRequiredDescription
statusquerylist of string (comma-separated)noList of statuses to include, for example open and assigned (on REST, comma-separated: open,assigned). Default is open,assigned.
channel_idsquerylist of integer (comma-separated)noList of channel ids from list_channels, for example 12 and 15 (on REST, comma-separated: 12,15). Narrows the call to these channels. An id that is not one of your channels gives not_found.
assignee_idqueryintegernoOnly tickets assigned to this agent. Ids come from list_agents.
needs_replyquerybooleannoTrue returns open or assigned tickets that are unread and where the customer sent the last message.
tag_idqueryintegernoOnly tickets with this tag (id from list_tags). Needs a time range of 7 days or less.
active_within_daysqueryintegernoOnly tickets with a message in the last N days, 1 to 30. Do not send it together with start and end.
startquerystringnoFirst day of a window, YYYY-MM-DD. Send start and end together, at most 30 days apart.
endquerystringnoLast day of the window, YYYY-MM-DD. The whole end day is included.
timezonequerystringnoIANA timezone name, for example Asia/Kolkata. It decides where a date starts and ends. If you omit it and every brand behind your channels shares one timezone, that one is used. If the brands use different timezones, the call fails with validation_error and you must pass timezone.
cursorquerystringnoThe next_cursor value of the previous response. Pass it back unchanged to get the next page. A cursor that this API did not return fails with validation_error.

Responses

GET /api/v1/tickets/{ticket}

Get one ticket (get_ticket)

Get one ticket: status, assignee, tags, channel, customer name and contact, and the time of the last customer message. Pass the ticket number (123) or an R ref of the ticket (R456, the number in the ticket header; older R refs of the same ticket also work). The customer phone is left out when the owner of the key may not see phone numbers. A ticket that does not exist and a ticket you may not see both give not_found. Cost: 2 points.

Scope: conversations:read

Parameters

NameInTypeRequiredDescription
ticketpathstringyesThe ticket. Use the ticket number (123) or an R ref of the ticket (R456, the number in the ticket header; older R refs of the same ticket also work). A ticket you cannot see gives not_found, the same as a ticket that does not exist.

Responses

POST /api/v1/messages/batch

Get messages of tickets (get_messages)

Get the messages of up to 20 tickets in one call. Each ticket returns its newest messages, at most 20 (set limit to ask for fewer), oldest first, as plain text. Internal notes are included and have type comment. History events are not messages and are not included. To read older messages of a ticket, send that ticket's next_cursor from the last response in cursors, keyed by the ticket. A null next_cursor means there are no older messages. If the answer would be very large, each ticket gets fewer messages than asked and next_cursor tells you where to continue. This is a read operation. It uses POST only because cursors is a map. Cost: 1 point plus the most messages it can return (tickets times limit).

Scope: conversations:read

JSON body

{
  "type": "object",
  "additionalProperties": false,
  "required": [
    "ticket_ids"
  ],
  "properties": {
    "ticket_ids": {
      "type": "array",
      "minItems": 1,
      "maxItems": 20,
      "items": {
        "type": [
          "string",
          "integer"
        ],
        "pattern": "^(R|r)?#?[0-9]{1,19}$",
        "minimum": 1
      },
      "description": "Tickets to read: ticket numbers (123) or refs (R456), at most 20."
    },
    "limit": {
      "type": "integer",
      "minimum": 1,
      "maximum": 20,
      "description": "Messages per ticket. Default and maximum 20."
    },
    "cursors": {
      "type": "object",
      "additionalProperties": {
        "type": "string"
      },
      "description": "Map from a ticket (as sent in ticket_ids) to the next_cursor of an earlier response, to read older messages of that ticket.",
      "maxProperties": 20
    }
  }
}

Responses

GET /api/v1/activity

Get tickets with new messages (get_new_activity)

Get tickets that received a new message after a given time, with those messages. Use it to poll for what is new. Results are oldest activity first, at most 20 tickets per page and at most 20 new messages per ticket (messages_truncated says some were left out; get_messages can fetch them). Changes of status, assignee or tags without a new message are not reported. To poll: start with since, read pages by passing next_cursor as cursor until it is null, then use the last item's ticket.last_message_at as the next since. Cost: 401 points (1 plus 20 tickets times 20 messages).

Scope: conversations:read

Parameters

NameInTypeRequiredDescription
sincequerystringyesOnly messages after this time. Date and time with a timezone, for example 2026-10-16T09:30:00Z. Without a timezone UTC is used.
channel_idsquerylist of integer (comma-separated)noList of channel ids from list_channels, for example 12 and 15 (on REST, comma-separated: 12,15). Narrows the call to these channels. An id that is not one of your channels gives not_found.
cursorquerystringnoThe next_cursor value of the previous response. Pass it back unchanged to get the next page. A cursor that this API did not return fails with validation_error.

Responses

GET /api/v1/counts

Get live ticket counts (get_counts)

Get live counts: unassigned tickets per channel, assigned tickets per agent, open tickets per tag. Use it instead of paging through list_tickets to count. assigned_by_agent and open_by_tag are null unless the owner of the key can see every channel of the account. counts is null when the account has no live counters (seeding is false) or when they are still being built (seeding is true: try again in about a minute). Cost: 1 point.

Scope: conversations:read

Responses

GET /api/v1/search

Search conversations (search_conversations)

Search message text for a keyword or phrase. Returns up to limit conversations (default 50, at most 200), best match first, each with the ticket and the matching text. The search finds at most 200 matches across the whole account, then your channel access is applied, so a response can hold fewer than limit items. truncated is true when more matches may exist: send a larger limit, or a more specific query. Internal notes, WhatsApp templates, fast replies and bot replies are not searched. There is no date filter and no cursor. If the search times out the call gives temporarily_unavailable: retry shortly. Searches do not count toward the daily conversation limit. Cost: 1 point plus limit (51 by default).

Scope: conversations:read

Parameters

NameInTypeRequiredDescription
queryquerystringyesThe words to search for, 1 to 500 characters.
limitqueryintegernoMost conversations to return, 1 to 200. Default 50.

Responses

Lookups

Ids for filters and changes, and the calling credential. Scope conversations:read.

GET /api/v1/channels

List channels (list_channels)

List the channels this credential can see: channel_id, name, type, brand_id and status. Use the channel_id values for filters. status is live, disconnected (needs to be authorised again), disabled or setup_incomplete. Only a live channel receives messages. Two channels can have similar names, for example an old and a new WhatsApp number: check type, brand_id and status. Cost: 1 point.

Scope: conversations:read

Responses

GET /api/v1/brands

List brands (list_brands)

List the brands that have at least one channel this credential can see. Other brands of the account stay hidden. Each item has brand_id and name. Cost: 1 point.

Scope: conversations:read

Responses

GET /api/v1/agents

List agents (list_agents)

List the team members of the account: agent_id, name and whether they are active. Use the agent_id as assignee_id in list_tickets and as agent_id in assign_ticket. A person who was invited and has not accepted (is_active false) is listed, but you cannot assign tickets to them. Removed users are not listed. Cost: 1 point.

Scope: conversations:read

Responses

GET /api/v1/tags

List tags (list_tags)

List the tags of the account: tag_id and name. Use the tag_id as tag_id in list_tickets and as tag_ids in add_tags and remove_tags. Hidden tags (internal and AI tags) and deleted tags are not listed. The list is not paginated. Cost: 1 point.

Scope: conversations:read

Responses

GET /api/v1/statuses

List ticket statuses (list_statuses)

List the ticket statuses the API uses (the status field holds the value you send as status): open (not assigned), assigned and closed. Invisible and archived tickets are never shown. Cost: 1 point.

Scope: conversations:read

Responses

GET /api/v1/whoami

Describe the calling credential (whoami)

Describe the credential that makes the call: its name, kind (key or connection), scopes, owner, account, expiry date and the limits that apply to the account now. Use it to check that a key works and to see what it may do. Cost: 1 point.

Scope: conversations:read

Responses

Marketing

Broadcast and automation statistics. Scope marketing:read.

GET /api/v1/marketing/broadcasts

Get broadcast statistics (get_broadcast_stats)

Get statistics per broadcast in a date window: sent, delivered, read, orders, revenue and WhatsApp fees. Only WhatsApp channels this credential can see are covered, all of them by default, or the ones in channel_ids. Each row belongs to one channel; there are no totals across channels. Rows come newest first, at most 100 per page (limit). Pass next_cursor as cursor for the next page. The window is at most 31 days per call: for a longer report make one call per month. For bulk history of conversations use start_export. Money: revenue is in revenue_currency (the Shopify store currency of the channel's brand). whatsapp_fees is the WhatsApp charge deducted from the DelightChat wallet, always in USD (whatsapp_fees_currency). Never add or compare revenue and fees without converting them to one currency first. If the user wants one currency, convert at the current exchange rate and tell the user the rate and the date you used. The API does no conversion. Cost: 1 point plus limit (101 by default).

Scope: marketing:read

Parameters

NameInTypeRequiredDescription
startquerystringyesFirst day of the window, YYYY-MM-DD, read in the timezone.
endquerystringyesLast day of the window, YYYY-MM-DD. The whole end day is included. The window is at most 31 days.
timezonequerystringnoIANA timezone name, for example Asia/Kolkata. It decides where a date starts and ends. If you omit it and every brand behind your channels shares one timezone, that one is used. If the brands use different timezones, the call fails with validation_error and you must pass timezone.
channel_idsquerylist of integer (comma-separated)noList of channel ids from list_channels, for example 12 and 15 (on REST, comma-separated: 12,15). Narrows the call to these channels. An id that is not one of your channels gives not_found.
cursorquerystringnoThe next_cursor value of the previous response. Pass it back unchanged to get the next page. A cursor that this API did not return fails with validation_error.
limitqueryintegernoRows per page, 1 to 100. Default 100.

Responses

GET /api/v1/marketing/automations

Get automation statistics (get_automation_stats)

Get the same statistics per automation in a date window. Only WhatsApp channels this credential can see are covered, all of them by default, or the ones in channel_ids. Each row belongs to one channel; there are no totals across channels. Rows come newest first, at most 100 per page (limit). Pass next_cursor as cursor for the next page. The window is at most 31 days per call: for a longer report make one call per month. For bulk history of conversations use start_export. Money: revenue is in revenue_currency (the Shopify store currency of the channel's brand). whatsapp_fees is the WhatsApp charge deducted from the DelightChat wallet, always in USD (whatsapp_fees_currency). Never add or compare revenue and fees without converting them to one currency first. If the user wants one currency, convert at the current exchange rate and tell the user the rate and the date you used. The API does no conversion. Cost: 1 point plus limit (101 by default).

Scope: marketing:read

Parameters

NameInTypeRequiredDescription
startquerystringyesFirst day of the window, YYYY-MM-DD, read in the timezone.
endquerystringyesLast day of the window, YYYY-MM-DD. The whole end day is included. The window is at most 31 days.
timezonequerystringnoIANA timezone name, for example Asia/Kolkata. It decides where a date starts and ends. If you omit it and every brand behind your channels shares one timezone, that one is used. If the brands use different timezones, the call fails with validation_error and you must pass timezone.
channel_idsquerylist of integer (comma-separated)noList of channel ids from list_channels, for example 12 and 15 (on REST, comma-separated: 12,15). Narrows the call to these channels. An id that is not one of your channels gives not_found.
cursorquerystringnoThe next_cursor value of the previous response. Pass it back unchanged to get the next page. A cursor that this API did not return fails with validation_error.
limitqueryintegernoRows per page, 1 to 100. Default 100.

Responses

Changes

Ticket changes. Scope tickets:write. Each real change counts against the hourly change cap.

POST /api/v1/tickets/{ticket}/tags

Add tags to a ticket (add_tags)

Add tags to a ticket. Tags already on the ticket are kept and not added twice. This changes data. Each real change counts against the hourly change cap of the key, and it is written to the ticket history as via <key or app name> (<admin>). A call that would change nothing returns changed false, writes nothing and does not count toward the cap, but it still uses request points. Answers: ticket (after the call), changed, change {action, before, after}. A tag id that is hidden, deleted or unknown gives not_found with the ids in details.tag_ids.

Scope: tickets:write

Parameters

NameInTypeRequiredDescription
ticketpathstringyesThe ticket. Use the ticket number (123) or an R ref of the ticket (R456, the number in the ticket header; older R refs of the same ticket also work). A ticket you cannot see gives not_found, the same as a ticket that does not exist.

JSON body

{
  "type": "object",
  "additionalProperties": false,
  "required": [
    "tag_ids"
  ],
  "properties": {
    "tag_ids": {
      "type": "array",
      "items": {
        "type": "integer",
        "minimum": 1
      },
      "minItems": 1,
      "maxItems": 50,
      "description": "Tag ids from list_tags, at most 50. A repeated id counts once. A hidden, deleted or unknown tag gives not_found."
    }
  }
}

Responses

POST /api/v1/tickets/{ticket}/tags/remove

Remove tags from a ticket (remove_tags)

Remove tags from a ticket. Tags that are not on the ticket are skipped. This changes data. Each real change counts against the hourly change cap of the key, and it is written to the ticket history as via <key or app name> (<admin>). A call that would change nothing returns changed false, writes nothing and does not count toward the cap, but it still uses request points. Answers: ticket (after the call), changed, change {action, before, after}.

Scope: tickets:write

Parameters

NameInTypeRequiredDescription
ticketpathstringyesThe ticket. Use the ticket number (123) or an R ref of the ticket (R456, the number in the ticket header; older R refs of the same ticket also work). A ticket you cannot see gives not_found, the same as a ticket that does not exist.

JSON body

{
  "type": "object",
  "additionalProperties": false,
  "required": [
    "tag_ids"
  ],
  "properties": {
    "tag_ids": {
      "type": "array",
      "items": {
        "type": "integer",
        "minimum": 1
      },
      "minItems": 1,
      "maxItems": 50,
      "description": "Tag ids from list_tags, at most 50. A repeated id counts once. A hidden, deleted or unknown tag gives not_found."
    }
  }
}

Responses

PUT /api/v1/tickets/{ticket}/assignee

Assign a ticket to an agent (assign_ticket)

Assign or reassign a ticket to an active team member. Get agent ids from list_agents. A closed ticket is reopened first, as in the app, and then assigned. This API cannot unassign a ticket. A person who has not accepted the invitation, or the AI chatbot (is_ai_chatbot true in list_agents), gives validation_error. This changes data. Each real change counts against the hourly change cap of the key, and it is written to the ticket history as via <key or app name> (<admin>). A call that would change nothing returns changed false, writes nothing and does not count toward the cap, but it still uses request points. Answers: ticket (after the call), changed, change {action, before, after}.

Scope: tickets:write

Parameters

NameInTypeRequiredDescription
ticketpathstringyesThe ticket. Use the ticket number (123) or an R ref of the ticket (R456, the number in the ticket header; older R refs of the same ticket also work). A ticket you cannot see gives not_found, the same as a ticket that does not exist.

JSON body

{
  "type": "object",
  "additionalProperties": false,
  "required": [
    "agent_id"
  ],
  "properties": {
    "agent_id": {
      "type": "integer",
      "minimum": 1,
      "description": "The agent to assign, from list_agents."
    }
  }
}

Responses

PUT /api/v1/tickets/{ticket}/status

Open, close or reopen a ticket (set_ticket_status)

Close a ticket or open it again. status closed closes an open or assigned ticket, and may send the customer a CSAT survey if auto-CSAT is on for that channel. status open and status reopen are the same: they reopen a closed ticket, and change nothing on a ticket that is already open or assigned. To assign a ticket use assign_ticket. This changes data. Each real change counts against the hourly change cap of the key, and it is written to the ticket history as via <key or app name> (<admin>). A call that would change nothing returns changed false, writes nothing and does not count toward the cap, but it still uses request points. Answers: ticket (after the call), changed, change {action, before, after}.

Scope: tickets:write

Parameters

NameInTypeRequiredDescription
ticketpathstringyesThe ticket. Use the ticket number (123) or an R ref of the ticket (R456, the number in the ticket header; older R refs of the same ticket also work). A ticket you cannot see gives not_found, the same as a ticket that does not exist.

JSON body

{
  "type": "object",
  "additionalProperties": false,
  "required": [
    "status"
  ],
  "properties": {
    "status": {
      "type": "string",
      "enum": [
        "open",
        "closed",
        "reopen"
      ],
      "description": "closed closes the ticket. open and reopen reopen it."
    }
  }
}

Responses

POST /api/v1/tickets/{ticket}/notes

Add an internal note (add_internal_note)

Add an internal note to a ticket. The note is visible only to the team, labelled via <key or app name> (<admin>), and is never sent to the customer. No operation of this API sends a message to a customer. Write @[agent_id] to mention an agent and notify them; a mention that is not an agent of this account refuses the whole note with validation_error and nothing is written. Put a space after each @[agent_id] mention (older mobile builds only recognise a mention followed by a space). To retry safely after a timeout, send an Idempotency-Key header: the same key from the same credential within 24 hours returns the first response and writes no second note. In MCP the same value is the idempotency_key parameter. A key already used on another ticket is refused with validation_error. This changes data. Each real change counts against the hourly change cap of the key, and it is written to the ticket history as via <key or app name> (<admin>). A call that would change nothing returns changed false, writes nothing and does not count toward the cap, but it still uses request points. Answers: ticket (after the call), changed, change {action, before, after}.

Scope: tickets:write

Parameters

NameInTypeRequiredDescription
ticketpathstringyesThe ticket. Use the ticket number (123) or an R ref of the ticket (R456, the number in the ticket header; older R refs of the same ticket also work). A ticket you cannot see gives not_found, the same as a ticket that does not exist.
Idempotency-KeyheaderstringnoA unique string you choose for this note, at most 200 characters. If you send the same key again with the same credential within 24 hours, the first response is returned and no second note is written. Use it when you retry after a timeout.

JSON body

{
  "type": "object",
  "additionalProperties": false,
  "required": [
    "text"
  ],
  "properties": {
    "text": {
      "type": "string",
      "minLength": 1,
      "maxLength": 10000,
      "description": "The note, at most 10,000 characters."
    }
  }
}

Responses

Exports

Bulk history export. Scope conversations:read.

POST /api/v1/exports

Start a bulk history export (start_export)

Start a bulk export of the messages of a date window. Use it for history of many conversations; do not page through get_messages. It covers every channel this credential can see, or only channel_ids. start and end are dates (YYYY-MM-DD, the end day is included, at most 92 days), read in the timezone. The export runs in the background and can take hours. The answer has the export_id; use get_export to follow it. Limits: one queued or running export per account (a second start gives limit_reached with the active export_id in details), 5 starts per account per UTC day, and 3 running across the platform (the rest wait queued). The file is gzipped JSON lines, one message per line in the get_messages shape. Customer phone numbers are left out when the owner of the credential may not see phone numbers. It is deleted 7 days after it is built. Cost: 1 point.

Scope: conversations:read

JSON body

{
  "type": "object",
  "additionalProperties": false,
  "required": [
    "start",
    "end"
  ],
  "properties": {
    "start": {
      "type": "string",
      "format": "date",
      "description": "First day, YYYY-MM-DD."
    },
    "end": {
      "type": "string",
      "format": "date",
      "description": "Last day, YYYY-MM-DD. The whole end day is included. At most 92 days from start."
    },
    "channel_ids": {
      "type": "array",
      "items": {
        "type": "integer",
        "minimum": 1
      },
      "minItems": 1,
      "description": "Only these channels (from list_channels). Default is every channel this credential can see."
    },
    "timezone": {
      "type": "string",
      "description": "IANA timezone name, for example Asia/Kolkata. Required when the brands behind your channels use different timezones."
    }
  }
}

Responses

GET /api/v1/exports

List recent exports (list_exports)

List the latest 20 exports of the account, newest first, in the get_export shape without download_url. Use it to find an export_id you lost. Cost: 1 point.

Scope: conversations:read

Responses

GET /api/v1/exports/{id}

Get the progress of an export (get_export)

Get the status of one export: status, phase, progress (conversations found and done, percent, messages), queue_position while queued, started_at, estimated_finish_at while running, error if it failed, and, once status is done, a download_url that is valid for 1 hour. Polling: an export can take hours. Check every few minutes (each check costs 1 point), not every few seconds, and tell the user the progress each time. Stop when status is done, failed or expired. paused is normal and resumes by itself when the platform is less busy. Call get_export again for a new download_url once the first one has expired. Only the owner who started the export gets the link. After 7 days the status is expired and the file is gone.

Scope: conversations:read

Parameters

NameInTypeRequiredDescription
idpathintegeryesThe export_id from start_export or list_exports.

Responses

Schemas

Error
{
  "type": "object",
  "required": [
    "error"
  ],
  "properties": {
    "error": {
      "type": "object",
      "required": [
        "code",
        "message"
      ],
      "properties": {
        "code": {
          "type": "string",
          "enum": [
            "invalid_key",
            "key_expired",
            "account_not_available",
            "owner_not_admin",
            "missing_scope",
            "not_found",
            "limit_reached",
            "validation_error",
            "temporarily_unavailable",
            "internal_error"
          ],
          "description": "Stable machine-readable code. Branch on this, not on message."
        },
        "message": {
          "type": "string",
          "description": "A human-readable explanation. Its wording can change."
        },
        "retry_after": {
          "type": "integer",
          "minimum": 1,
          "description": "Seconds to wait before you retry. Present on limit_reached and on some temporarily_unavailable errors. Over HTTP it is also the Retry-After header."
        },
        "details": {
          "type": "object",
          "additionalProperties": true,
          "description": "Extra facts. validation_error names the bad parameter in details.field. limit_reached on start_export carries details.export_id of the export that is already active."
        }
      }
    }
  }
}
Window
{
  "type": "object",
  "description": "The date window the server used, echoed so you can check it.",
  "required": [
    "timezone",
    "start",
    "end",
    "start_utc",
    "end_utc"
  ],
  "properties": {
    "timezone": {
      "type": "string",
      "description": "The IANA timezone used."
    },
    "start": {
      "type": "string",
      "format": "date"
    },
    "end": {
      "type": "string",
      "format": "date",
      "description": "The last day, included in full."
    },
    "start_utc": {
      "type": "string",
      "format": "date-time",
      "description": "Start of the window in UTC (inclusive)."
    },
    "end_utc": {
      "type": "string",
      "format": "date-time",
      "description": "End of the window in UTC (exclusive: local midnight after the end day)."
    }
  }
}
Ticket
{
  "type": "object",
  "description": "One ticket (a customer conversation). Customer phone fields are left out when the owner of the credential may not see phone numbers.",
  "required": [
    "ticket_id",
    "status",
    "channel_id",
    "tags"
  ],
  "properties": {
    "ticket_id": {
      "type": "integer",
      "description": "The ticket number, shown as # in the app."
    },
    "current_ref": {
      "type": [
        "string",
        "null"
      ],
      "description": "R plus the id of the latest sub-conversation of this ticket (the number in the ticket header in the app), for example R456. Accepted wherever a ticket is expected."
    },
    "status": {
      "type": "string",
      "enum": [
        "open",
        "assigned",
        "closed"
      ]
    },
    "brand_id": {
      "type": [
        "integer",
        "null"
      ]
    },
    "channel_id": {
      "type": "integer",
      "description": "The channel (see list_channels)."
    },
    "is_read": {
      "type": "boolean"
    },
    "assignee": {
      "type": [
        "object",
        "null"
      ],
      "description": "The assigned agent, or null when no one is assigned.",
      "properties": {
        "agent_id": {
          "type": "integer"
        },
        "name": {
          "type": [
            "string",
            "null"
          ]
        }
      }
    },
    "tags": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "tag_id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          }
        }
      }
    },
    "last_message_at": {
      "type": [
        "string",
        "null"
      ],
      "format": "date-time",
      "description": "Time of the latest message in the ticket (UTC)."
    },
    "last_customer_message_at": {
      "type": [
        "string",
        "null"
      ],
      "format": "date-time",
      "description": "Time of the latest message the customer sent (UTC)."
    },
    "subject": {
      "type": [
        "string",
        "null"
      ],
      "description": "Email subject or similar. Blank when it is only a phone number and the owner may not see phones."
    },
    "customer": {
      "type": "object",
      "properties": {
        "customer_id": {
          "type": [
            "integer",
            "null"
          ]
        },
        "name": {
          "type": [
            "string",
            "null"
          ]
        },
        "email": {
          "type": [
            "string",
            "null"
          ]
        },
        "phone": {
          "type": [
            "string",
            "null"
          ],
          "description": "Only present when the owner of the credential may see customer phone numbers."
        }
      }
    }
  }
}
Message
{
  "type": "object",
  "description": "One message as plain text. HTML and attachments are not included. A phone number the customer typed inside the text is left as it is.",
  "required": [
    "message_id",
    "ticket_id",
    "type",
    "sent_at"
  ],
  "properties": {
    "message_id": {
      "type": "integer"
    },
    "ticket_id": {
      "type": "integer"
    },
    "ref": {
      "type": [
        "string",
        "null"
      ],
      "description": "The R ref of the sub-conversation the message belongs to (R plus the sub-conversation id)."
    },
    "type": {
      "type": "string",
      "enum": [
        "inbound_message",
        "outbound_message",
        "comment",
        "internal_message"
      ],
      "description": "inbound_message is from the customer, outbound_message is a reply from the team or a bot, comment is an internal note that only the team sees, internal_message is the message of a ticket created by hand in the app."
    },
    "sender": {
      "type": "object",
      "description": "Who sent the message. A teammate, API key or AI app sender carries agent_id, a customer sender carries customer_id, and any other source (the AI chatbot, a brand or bot message) carries no id.",
      "properties": {
        "agent_id": {
          "type": [
            "integer",
            "null"
          ],
          "description": "The agent who sent the message. Present for the teammate, dcuser, api_key and mcp sources, the same id as agent_id on a ticket assignee."
        },
        "customer_id": {
          "type": [
            "integer",
            "null"
          ],
          "description": "The customer who sent the message. Present for the customer source, the same id as customer_id on a ticket."
        },
        "source": {
          "type": [
            "string",
            "null"
          ]
        },
        "name": {
          "type": [
            "string",
            "null"
          ]
        },
        "whatsapp_phone": {
          "type": [
            "string",
            "null"
          ],
          "description": "Only present when the owner may see customer phone numbers."
        }
      }
    },
    "text": {
      "type": [
        "string",
        "null"
      ],
      "description": "Plain text. For email it is the new part only, without the quoted history."
    },
    "sent_at": {
      "type": "string",
      "format": "date-time"
    }
  }
}
TicketList
{
  "type": "object",
  "required": [
    "items",
    "next_cursor"
  ],
  "properties": {
    "items": {
      "type": "array",
      "items": {
        "$ref": "#/components/schemas/Ticket"
      }
    },
    "next_cursor": {
      "type": [
        "string",
        "null"
      ],
      "description": "Pass as cursor to get the next page. Null on the last page."
    },
    "window": {
      "$ref": "#/components/schemas/Window",
      "description": "Present only when you sent start and end."
    }
  }
}
MessagesResponse
{
  "type": "object",
  "required": [
    "items"
  ],
  "properties": {
    "items": {
      "type": "array",
      "description": "One entry per requested ticket, in the order you asked.",
      "items": {
        "type": "object",
        "required": [
          "ticket_id",
          "messages",
          "next_cursor"
        ],
        "properties": {
          "ticket_id": {
            "type": "integer"
          },
          "messages": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Message"
            },
            "description": "The newest messages of the ticket, oldest first, so the thread reads top to bottom."
          },
          "next_cursor": {
            "type": [
              "string",
              "null"
            ],
            "description": "Send it in cursors for this ticket to read older messages. Null when there are no older messages."
          }
        }
      }
    }
  }
}
ActivityResponse
{
  "type": "object",
  "required": [
    "items",
    "next_cursor"
  ],
  "properties": {
    "items": {
      "type": "array",
      "items": {
        "type": "object",
        "required": [
          "ticket",
          "messages",
          "messages_truncated"
        ],
        "properties": {
          "ticket": {
            "$ref": "#/components/schemas/Ticket"
          },
          "messages": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Message"
            },
            "description": "New messages after since, oldest first, at most 20 per ticket."
          },
          "messages_truncated": {
            "type": "boolean",
            "description": "True when older new messages were left out. Fetch them with get_messages."
          }
        }
      }
    },
    "next_cursor": {
      "type": [
        "string",
        "null"
      ],
      "description": "Pass as cursor for the next page. Null when you have caught up."
    }
  }
}
SearchResponse
{
  "type": "object",
  "required": [
    "items",
    "truncated"
  ],
  "properties": {
    "items": {
      "type": "array",
      "items": {
        "type": "object",
        "required": [
          "ticket",
          "snippet"
        ],
        "properties": {
          "ticket": {
            "$ref": "#/components/schemas/Ticket"
          },
          "snippet": {
            "type": "string",
            "description": "The matching text."
          }
        }
      }
    },
    "truncated": {
      "type": "boolean",
      "description": "True when more matches may exist than were returned: the search reached its 200-match cap, or more conversations matched than limit."
    }
  }
}
CountsResponse
{
  "type": "object",
  "required": [
    "counts",
    "seeding"
  ],
  "properties": {
    "counts": {
      "type": [
        "object",
        "null"
      ],
      "description": "Null when the account has no live counters (seeding false) or its counters are still being built (seeding true).",
      "properties": {
        "unassigned_by_channel": {
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "channel_id": {
                "type": "integer"
              },
              "name": {
                "type": "string"
              },
              "count": {
                "type": "integer"
              }
            }
          }
        },
        "assigned_by_agent": {
          "type": [
            "array",
            "null"
          ],
          "description": "Null unless the owner can see every channel of the account.",
          "items": {
            "type": "object",
            "properties": {
              "agent_id": {
                "type": "integer"
              },
              "name": {
                "type": "string"
              },
              "count": {
                "type": "integer"
              },
              "is_ai_chatbot": {
                "type": "boolean",
                "description": "True for the account's AI chatbot, which is not a person."
              }
            }
          }
        },
        "open_by_tag": {
          "type": [
            "array",
            "null"
          ],
          "description": "Open and assigned tickets per visible tag. Null unless the owner can see every channel, or while the tag counters are not ready.",
          "items": {
            "type": "object",
            "properties": {
              "tag_id": {
                "type": "integer"
              },
              "name": {
                "type": "string"
              },
              "count": {
                "type": "integer"
              }
            }
          }
        }
      }
    },
    "seeding": {
      "type": "boolean",
      "description": "True when the counters are being built. Retry in about a minute."
    }
  }
}
ChannelList
{
  "type": "object",
  "required": [
    "items"
  ],
  "properties": {
    "items": {
      "type": "array",
      "items": {
        "type": "object",
        "required": [
          "channel_id",
          "name",
          "type",
          "status"
        ],
        "properties": {
          "channel_id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "enum": [
              "facebook_messenger",
              "facebook_comment",
              "whatsapp_interakt",
              "whatsapp_superlemon",
              "whatsapp_gupshup_isv",
              "whatsapp_delightchat_cloud_isv",
              "instagram",
              "instagram_direct_messenger",
              "email",
              "manual",
              "widget_livechat"
            ]
          },
          "brand_id": {
            "type": [
              "integer",
              "null"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "live",
              "disconnected",
              "disabled",
              "setup_incomplete"
            ],
            "description": "Only a live channel receives messages. disconnected means the channel needs to be authorised again."
          },
          "re_auth_reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Set only for a WhatsApp Cloud number whose display name needs attention."
          }
        }
      }
    }
  }
}
BrandList
{
  "type": "object",
  "required": [
    "items"
  ],
  "properties": {
    "items": {
      "type": "array",
      "items": {
        "type": "object",
        "required": [
          "brand_id",
          "name"
        ],
        "properties": {
          "brand_id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          }
        }
      }
    }
  }
}
TagList
{
  "type": "object",
  "required": [
    "items"
  ],
  "properties": {
    "items": {
      "type": "array",
      "items": {
        "type": "object",
        "required": [
          "tag_id",
          "name"
        ],
        "properties": {
          "tag_id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          }
        }
      }
    }
  }
}
AgentList
{
  "type": "object",
  "required": [
    "items"
  ],
  "properties": {
    "items": {
      "type": "array",
      "items": {
        "type": "object",
        "required": [
          "agent_id",
          "name",
          "is_active",
          "is_ai_chatbot"
        ],
        "properties": {
          "agent_id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "is_active": {
            "type": "boolean",
            "description": "False for a person who was invited and has not accepted yet. You cannot assign tickets to them."
          },
          "is_ai_chatbot": {
            "type": "boolean",
            "description": "True for the account's AI chatbot, which is not a person. You cannot assign tickets to it from the API."
          }
        }
      }
    }
  }
}
StatusList
{
  "type": "object",
  "required": [
    "items"
  ],
  "properties": {
    "items": {
      "type": "array",
      "items": {
        "type": "object",
        "required": [
          "status",
          "label",
          "description"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "open",
              "assigned",
              "closed"
            ]
          },
          "label": {
            "type": "string"
          },
          "description": {
            "type": "string"
          }
        }
      }
    }
  }
}
Whoami
{
  "type": "object",
  "required": [
    "name",
    "kind",
    "scopes",
    "owner",
    "account",
    "expires_at",
    "limits"
  ],
  "properties": {
    "name": {
      "type": "string",
      "description": "The name of the API key, or of the AI app for a connection."
    },
    "kind": {
      "type": "string",
      "enum": [
        "key",
        "connection"
      ]
    },
    "scopes": {
      "type": "array",
      "items": {
        "type": "string",
        "enum": [
          "conversations:read",
          "marketing:read",
          "tickets:write"
        ]
      }
    },
    "owner": {
      "type": "object",
      "properties": {
        "agent_id": {
          "type": "integer"
        },
        "name": {
          "type": "string"
        }
      }
    },
    "account": {
      "type": "object",
      "properties": {
        "account_id": {
          "type": "integer"
        },
        "name": {
          "type": [
            "string",
            "null"
          ]
        }
      }
    },
    "expires_at": {
      "type": "string",
      "format": "date-time"
    },
    "limits": {
      "type": "object",
      "description": "The limits that apply to the account now. changes_per_hour is per credential. The other numbers are per account.",
      "properties": {
        "enforcement": {
          "type": "string",
          "enum": [
            "shadow",
            "target"
          ]
        },
        "tier": {
          "type": [
            "string",
            "null"
          ],
          "enum": [
            "small",
            "medium",
            "large",
            null
          ]
        },
        "daily_messages": {
          "type": "integer",
          "description": "Messages you can read per UTC day."
        },
        "daily_conversations": {
          "type": "integer",
          "description": "Tickets you can read per UTC day with list_tickets and get_ticket. Searches do not count."
        },
        "changes_per_hour": {
          "type": "integer"
        },
        "request_points": {
          "type": "object",
          "properties": {
            "capacity": {
              "type": "integer"
            },
            "refill_per_minute": {
              "type": "integer"
            }
          }
        },
        "max_concurrent_requests": {
          "type": "integer"
        }
      }
    }
  }
}
MoneyFields
{
  "type": "object",
  "description": "Shared figures of a marketing row. Money is returned as stored. Nothing is converted.",
  "properties": {
    "channel_id": {
      "type": "integer"
    },
    "sent": {
      "type": "integer"
    },
    "delivered": {
      "type": "integer"
    },
    "read": {
      "type": "integer"
    },
    "orders": {
      "type": "integer"
    },
    "revenue": {
      "type": "number",
      "description": "Order revenue in revenue_currency, the Shopify store currency of the channel's brand. Do not add it to whatsapp_fees or compare it with whatsapp_fees without converting first."
    },
    "revenue_currency": {
      "type": [
        "string",
        "null"
      ],
      "description": "Currency of revenue. Null when the brand has no Shopify store."
    },
    "whatsapp_fees": {
      "type": "number",
      "description": "The WhatsApp conversation charge deducted from the DelightChat wallet, in USD (whatsapp_fees_currency)."
    },
    "whatsapp_fees_currency": {
      "type": "string",
      "enum": [
        "USD"
      ],
      "description": "Currency of whatsapp_fees. Always USD at this time."
    },
    "store_currency": {
      "type": [
        "string",
        "null"
      ],
      "description": "Shopify store currency of the channel's brand. Same value as revenue_currency."
    }
  }
}
BroadcastStat
{
  "allOf": [
    {
      "$ref": "#/components/schemas/MoneyFields"
    },
    {
      "type": "object",
      "required": [
        "broadcast_id"
      ],
      "properties": {
        "broadcast_id": {
          "type": "integer"
        },
        "name": {
          "type": [
            "string",
            "null"
          ]
        },
        "status": {
          "type": [
            "string",
            "null"
          ]
        },
        "created_at": {
          "type": [
            "string",
            "null"
          ],
          "format": "date-time"
        }
      }
    }
  ]
}
AutomationStat
{
  "allOf": [
    {
      "$ref": "#/components/schemas/MoneyFields"
    },
    {
      "type": "object",
      "required": [
        "automation_id"
      ],
      "properties": {
        "automation_id": {
          "type": "integer"
        },
        "name": {
          "type": [
            "string",
            "null"
          ],
          "description": "The automation's own name, or its type when it has none."
        },
        "type": {
          "type": [
            "string",
            "null"
          ]
        },
        "enabled": {
          "type": [
            "boolean",
            "null"
          ]
        },
        "created_at": {
          "type": [
            "string",
            "null"
          ],
          "format": "date-time"
        }
      }
    }
  ]
}
BroadcastStatsResponse
{
  "type": "object",
  "required": [
    "items",
    "next_cursor",
    "window"
  ],
  "properties": {
    "items": {
      "type": "array",
      "items": {
        "$ref": "#/components/schemas/BroadcastStat"
      }
    },
    "next_cursor": {
      "type": [
        "string",
        "null"
      ]
    },
    "window": {
      "$ref": "#/components/schemas/Window"
    }
  }
}
AutomationStatsResponse
{
  "type": "object",
  "required": [
    "items",
    "next_cursor",
    "window"
  ],
  "properties": {
    "items": {
      "type": "array",
      "items": {
        "$ref": "#/components/schemas/AutomationStat"
      }
    },
    "next_cursor": {
      "type": [
        "string",
        "null"
      ]
    },
    "window": {
      "$ref": "#/components/schemas/Window"
    }
  }
}
ChangeResponse
{
  "type": "object",
  "required": [
    "ticket",
    "changed",
    "change"
  ],
  "properties": {
    "ticket": {
      "$ref": "#/components/schemas/Ticket",
      "description": "The ticket after the call."
    },
    "changed": {
      "type": "boolean",
      "description": "False when the call would change nothing (the tag is already on the ticket, the ticket already has that status or assignee). Nothing is written, no history entry is made and the call does not count toward the hourly change cap."
    },
    "change": {
      "oneOf": [
        {
          "type": "null"
        },
        {
          "type": "object",
          "required": [
            "action",
            "before",
            "after"
          ],
          "properties": {
            "action": {
              "type": "string",
              "enum": [
                "add_tags",
                "remove_tags",
                "assign_ticket",
                "set_ticket_status",
                "add_internal_note"
              ]
            },
            "before": {
              "description": "Status: open, assigned or closed. Assignee: {agent_id, name} or null. Tags: [{tag_id, name}] (all visible tags on the ticket). Note: null.",
              "oneOf": [
                {
                  "type": "string"
                },
                {
                  "type": "object"
                },
                {
                  "type": "array"
                },
                {
                  "type": "null"
                }
              ]
            },
            "after": {
              "description": "Same form as before. For a note it is {block_id}.",
              "oneOf": [
                {
                  "type": "string"
                },
                {
                  "type": "object"
                },
                {
                  "type": "array"
                },
                {
                  "type": "null"
                }
              ]
            }
          }
        }
      ],
      "description": "What changed, or null when changed is false. The same before and after are written to the ticket history with the label via <key or app name> (<admin>)."
    }
  }
}
Export
{
  "type": "object",
  "required": [
    "export_id",
    "status",
    "phase",
    "progress"
  ],
  "properties": {
    "export_id": {
      "type": "integer"
    },
    "status": {
      "type": "string",
      "enum": [
        "queued",
        "running",
        "paused",
        "done",
        "failed",
        "expired"
      ],
      "description": "queued waits for a free slot. running is in progress. paused waits for the platform to be less busy and resumes by itself. done has a download_url. failed has an error. expired means the file was deleted after 7 days."
    },
    "phase": {
      "type": "string",
      "enum": [
        "listing",
        "fetching"
      ],
      "description": "listing counts the conversations first so percent is real. fetching reads the messages."
    },
    "progress": {
      "type": "object",
      "properties": {
        "conversations_found": {
          "type": "integer"
        },
        "conversations_done": {
          "type": "integer"
        },
        "percent": {
          "type": "integer",
          "minimum": 0,
          "maximum": 100
        },
        "messages": {
          "type": "integer",
          "description": "Messages written so far."
        }
      }
    },
    "queue_position": {
      "type": "integer",
      "description": "Only while queued."
    },
    "paused_reason": {
      "type": "string",
      "description": "Only while paused. platform_busy means the shared database budget is used up for now."
    },
    "started_at": {
      "type": [
        "string",
        "null"
      ],
      "format": "date-time"
    },
    "estimated_finish_at": {
      "type": "string",
      "format": "date-time",
      "description": "Only while running in the fetching phase, from the measured rate."
    },
    "error": {
      "type": "string",
      "description": "Only when the export failed."
    },
    "download_url": {
      "type": "string",
      "format": "uri",
      "description": "Only on get_export, when status is done and the caller is the owner who started the export. Valid for 1 hour. Call get_export again for a new link."
    },
    "expires_at": {
      "type": [
        "string",
        "null"
      ],
      "format": "date-time",
      "description": "When the file is deleted (7 days after it was built). Only when done or expired."
    }
  }
}
StartExportResponse
{
  "type": "object",
  "required": [
    "export_id",
    "status",
    "window"
  ],
  "properties": {
    "export_id": {
      "type": "integer"
    },
    "status": {
      "type": "string",
      "enum": [
        "queued"
      ]
    },
    "window": {
      "$ref": "#/components/schemas/Window"
    }
  }
}
ExportList
{
  "type": "object",
  "required": [
    "items"
  ],
  "properties": {
    "items": {
      "type": "array",
      "items": {
        "$ref": "#/components/schemas/Export"
      },
      "description": "The latest 20 exports of the account, newest first. No download_url."
    }
  }
}