Ticket messages
Read and add messages to ticket conversations.
Each ticket contains a thread of messages. Messages can be inbound (from the customer), outbound (sent by your team via email), or internal (private notes visible only to agents). Message ids start with msg_. The {id} path parameter is the ticket id; SUP- client ids are also accepted (see Tickets > Identifiers).
List messagesGET/v1/tickets/{id}/messages
Permission: read. Returns { "data": [...], "pagination": { "limit", "offset", "total" } } with messages ordered by created_at ascending (oldest first). Each message includes its attachments under message_files: [{ id, file_name, file_size, file_type, file_url, created_at }]. Draft and scheduled messages are included; check metadata.draft_status and metadata.scheduled_status to tell them apart.
| Parameter | Type | Default | Description |
|---|---|---|---|
| limit | number | 50 | Max 100. |
| offset | number | 0 | Pagination offset. |
| created_after | string | -- | ISO 8601 date for delta sync (only messages created at or after this date). 400 if not a valid date. |
Add a messagePOST/v1/tickets/{id}/messages
Permission: write. Adds a new message to the ticket thread.
| Field | Type | Required | Description |
|---|---|---|---|
| body | string | Conditional | Message text (legacy alias of body_text). Provide body/body_text OR body_html. |
| body_text | string | Conditional | Plain text message body. If body_html is omitted, it is generated from this text. |
| body_html | string | Conditional | HTML message body. If no text body is sent, the plain text is derived by stripping tags. |
| direction | string | No | inbound (default), outbound, or internal. Any other value returns 400. |
| from_email | string | No | Sender email stored on the message (outbound/internal). Defaults to the email of the API key owner. The email itself is always sent from the inbox address. |
| from_name | string | No | Sender display name stored on the message (outbound/internal). The email uses the inbox name as sender name. |
| attachments | array | No | Array of { fileName, fileData (base64), contentType }. |
| send_at | string | No | ISO 8601 date to schedule the email (outbound only). Must be in the future and at most 30 days ahead. Cannot be combined with is_draft. |
| is_draft | boolean | No | Save as draft without sending (outbound or internal only; 400 for inbound). Cannot be combined with send_at. |
Responses
| Status | Body | When |
|---|---|---|
| 201 | { "message": {...}, "attachments": [...] } | Message created (inbound, internal, or outbound sent immediately). |
| 202 | { "message": {...}, "attachments": [...], "scheduled": true, "scheduled_at": "ISO 8601" } | Outbound message with send_at, queued for delivery. |
| 201 | { "data": {...}, "attachments": [...], "draft": true } | Draft saved (is_draft: true). |
| 400 | { "error": "..." } | Missing body, invalid direction, invalid or out-of-range send_at, invalid draft/schedule combination. |
| 403 | { "error": "..." } | Account suspended or email sending suspended (bounce rate). Applies to every direction, including internal notes and drafts. |
| 404 | { "error": "Ticket not found" } | Ticket not found in your workspace (or the ticket has no customer or inbox). |
| 422 | { "error": "..." } | Outbound recipient is bounce-suppressed. See the note below. |
| 429 | { "error": "Limit exceeded", ... } | Email sending limit (outbound) or storage limit reached. |
Email delivery is asynchronous from the API point of view: if sending through the email provider fails after the message is stored, the request still returns 201 and the error is not reported in the response. An outbound message is only emailed when the ticket inbox has an email address configured; otherwise it is stored without sending.
Direction behavior
- outbound -- Sends a real email to the customer via AWS SES from the inbox address, with threading headers (In-Reply-To, References). Checks email rate limits and bounce suppressions. Moves open or review tickets to pending.
- inbound -- Simulates a customer reply. The sender is always the ticket customer (from_email/from_name are ignored). Auto-unarchives the ticket, reopens closed/resolved tickets to open and changes pending to review.
- internal -- Private note. No email is sent and the ticket status does not change.
Inbound, outbound and internal messages fire the message.created webhook (drafts do not until they are sent).
Scheduled messages
With send_at, the message is stored immediately with metadata { scheduled_at, scheduled_status: "pending" } and the email is queued for delivery at that time (returns 202). The ticket status change, email counter and message.created webhook happen when the request is made, not when the email is delivered. Use has_scheduled=true on GET /v1/tickets to find tickets with queued emails.
Drafts
Draft messages are saved without being sent. They are stored with metadata { draft_status: "draft", draft_saved_at }. Drafts do not trigger webhooks, status changes, or email sending until explicitly sent.
Saving a draft
Send a POST request with is_draft: true and direction outbound or internal. Attachments are uploaded normally. Returns 201 with { "data": {...}, "attachments": [...], "draft": true }.
Edit draftPATCH/v1/tickets/{id}/messages/{messageId}
Permission: write. Updates the body of a draft message. At least one of body_text or body_html is required (400 otherwise); the missing one is derived from the other. Only messages with draft_status = "draft" can be edited (400 otherwise). Returns { "data": {...} } with the updated message.
| Field | Type | Required | Description |
|---|---|---|---|
| body_text | string | Conditional | Updated plain text body. |
| body_html | string | Conditional | Updated HTML body. |
Send draftPOST/v1/tickets/{id}/messages/{messageId}/send
Permission: write. Sends a draft. Removes the draft metadata (adds sent_from_draft: true) and runs the normal flow for its direction: for outbound, email delivery from the inbox address, status update (open/review to pending) and email counter increment; message.created webhook for both outbound and internal. Returns 200 with { "data": {...}, "sent": true }, where data is the message with message_files. Returns 403 if the account or email sending is suspended, 422 if the recipient is bounce-suppressed and 429 if the email limit is reached.
Delete draftDELETE/v1/tickets/{id}/messages/{messageId}
Permission: write. Permanently deletes a draft message and its attachment records. Only messages with draft_status = "draft" can be deleted (400 otherwise). Returns { "id": "msg_...", "deleted": true }.
List attachmentsGET/v1/messages/{id}/attachments
Permission: read. {id} is the message id (msg_...). Returns all attachments of the message, oldest first, as { "attachments": [...], "count": n }. Each attachment includes id, message_id, file_name, file_size, file_type and file_url (use file_url to download the file; there is no separate download field). Returns 404 if the message does not belong to your workspace.