Tickets
Create Ticket
Create a new inbound ticket on a specific channel, as if the customer had written in. This is useful for integrating external forms or support widgets with There There. To start the conversation yourself and email the customer, use Compose Ticket instead.
POST /api/tickets
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
channel |
string | yes | The ULID of the channel |
subject |
string | yes | Ticket subject (max 255 characters) |
from_contact.email |
string | yes | Contact email address |
from_contact.name |
string | no | Contact name |
message.body |
string | yes | HTML body of the initial message |
message.attachment_ids |
array | no | Upload tokens to attach to the initial message. See Uploading files |
tags |
array | no | Tag names to put on the ticket. See Tagging by name |
You can find a channel's ULID on its settings page in There There. Open the channel, and the "Channel ID" is shown with a copy button.
Example Request
curl -X POST "https://there-there.app/api/tickets" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"channel": "01HX9F3K2M...",
"subject": "Cannot connect to the API",
"from_contact": {
"email": "john@customer.com",
"name": "John Doe"
},
"message": {
"body": "<p>I keep getting a 403 error when trying to connect.</p>"
}
}'
Example Response
{
"data": {
"id": 42,
"ulid": "01HX9F3K2M...",
"subject": "Cannot connect to the API",
"status": "open",
"channel": { "id": 1, "name": "Support", "type": "email", "color": "#3b82f6" },
"assignee": null,
"contact": {
"id": 12,
"ulid": "01HX9F3K2N...",
"name": "John Doe",
"email": "john@customer.com",
"avatar_url": null
},
"tags": [],
"created_at": "2025-06-01T10:30:00+00:00",
"updated_at": "2025-06-01T10:30:00+00:00"
}
}
The ticket will be processed through the standard inbound pipeline, which means workflows, AI title generation, notifications, and other automations will run as if the ticket arrived via email.
If the contact email does not exist yet, a new contact will be created automatically.
Compose Ticket
Start a new conversation with a customer: this is the API equivalent of "Create ticket" in the app. The first message is outbound and is emailed to the recipients, sent by the user the API token belongs to.
POST /api/tickets/compose
The channel has to be an email channel with verified DNS records, and the token has to have access to it.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
channel |
string | yes | The ULID of the channel to send through |
subject |
string | yes | Ticket subject (max 255 characters) |
to |
array | yes | Recipient email addresses. The first one becomes the ticket's contact |
cc |
array | no | CC email addresses |
bcc |
array | no | BCC email addresses |
message.body |
string | yes | HTML body of the message |
message.attachment_ids |
array | no | Upload tokens to attach to the message. See Uploading files |
custom_fields |
array | no | Custom field values to set on the ticket. See Custom fields |
tags |
array | no | Tag names to put on the ticket. See Tagging by name |
Example Request
curl -X POST "https://there-there.app/api/tickets/compose" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"channel": "01HX9F3K2M...",
"subject": "About your invoice",
"to": ["john@customer.com"],
"cc": ["accounting@customer.com"],
"message": {
"body": "<p>We noticed something on your last invoice.</p>"
}
}'
The response is the created ticket, in the same shape as Get Ticket.
The email is queued as soon as the ticket is created. Everything that runs on a new ticket runs here too: workflows, AI title generation, and notifications.
If a contact with the first recipient's email does not exist yet, a new contact will be created automatically.
Tagging by Name
POST /api/tickets, POST /api/tickets/compose and POST /api/tickets/import all accept a tags array of tag names, so you do not have to resolve ULIDs first.
curl -X POST "https://there-there.app/api/tickets/compose" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"channel": "01HX9F3K2M...",
"subject": "About your invoice",
"to": ["john@customer.com"],
"tags": ["Billing", "Urgent"],
"message": {
"body": "<p>We noticed something on your last invoice.</p>"
}
}'
Casing and surrounding whitespace are ignored, so billing, Billing and BILLING all find the same tag. Everything else has to match the tag's name exactly.
A name that matches no tag in the workspace is created on the spot, but only when the token belongs to a workspace admin. For anyone else the request answers 422 and no ticket is created, which keeps a typo from quietly adding a tag to the workspace. Create the tag up front (in the app, or with Create Tag) and the same call goes through.
A tag is stored under a simplified form of its name, so two names that only differ in punctuation, such as C# and C++, cannot both exist. Asking for a name that a workspace tag already occupies answers 422 and names the tag to use instead. So does sending two such names in one request.
You can send at most 50 names per request.
On Create Ticket and Compose Ticket, workflows that trigger on a tag being added run for these tags, just as they do when you tag through the app. They go on right after the ticket is created, so a workflow that triggers on the ticket arriving runs before them and does not see them. Import Historical Tickets runs no automations at all, tags included.
To change the tags on a ticket afterwards, use the tag endpoints.
Import Historical Tickets
To backfill past conversations from another helpdesk with their original timestamps and status (without sending email or triggering automations), use the dedicated import endpoint. See From any helpdesk using our API in the Importing tickets guide.
List Tickets
Retrieve a paginated list of tickets in the current workspace.
GET /api/tickets
Query Parameters
| Parameter | Type | Description |
|---|---|---|
per_page |
integer | Results per page (default: 25, max: 100) |
sort |
string | Sort field: created_at, updated_at, last_activity_at. Prefix with - for descending (default: -updated_at) |
filter[status] |
string | Comma separated statuses: open, waiting, closed, spam |
filter[channel_ulids] |
string | Comma separated channel ULIDs |
filter[tag_ulids] |
string | Comma separated tag ULIDs |
filter[search] |
string | Search term for subject and message content |
filter[assigned_to_me] |
boolean | Only tickets assigned to the authenticated user |
filter[unassigned] |
boolean | Only unassigned tickets |
filter[assigned_user_ulid] |
string | Only tickets assigned to the user with this ULID |
Comma separated filters also accept the repeated array form, so
filter[channel_ulids]=a,b and filter[channel_ulids][]=a&filter[channel_ulids][]=b
are the same request.
Only the filters listed here are accepted. A filter this table does not mention,
a ULID that belongs to another workspace, or a ULID that does not exist, answers
422 with the offending key in errors. It never answers an unfiltered list.
Example Request
curl "https://there-there.app/api/tickets?filter[status]=open&sort=-updated_at&per_page=10" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json"
Example Response
{
"data": [
{
"id": 42,
"ulid": "01HX9F3K2M...",
"subject": "Cannot reset my password",
"status": "open",
"channel": { "id": 1, "name": "Support", "type": "email", "color": "#3b82f6" },
"assignee": {
"id": 5,
"name": "Jane Smith",
"email": "jane@example.com",
"avatar_url": null,
"timezone": "UTC"
},
"contact": {
"id": 12,
"ulid": "01HX9F3K2N...",
"name": "John Doe",
"email": "john@customer.com",
"avatar_url": null
},
"tags": [{ "id": 3, "ulid": "01HX9F3K2P...", "name": "urgent", "color": "red" }],
"latest_message_preview": "I tried clicking the link but...",
"summary": null,
"created_at": "2025-06-01T10:30:00+00:00",
"updated_at": "2025-06-02T14:15:00+00:00"
}
],
"links": { "first": "...", "last": "...", "prev": null, "next": "..." },
"meta": { "current_page": 1, "last_page": 5, "per_page": 10, "total": 48 }
}
Get Ticket
Retrieve a single ticket with its messages.
GET /api/tickets/{ticket_ulid}
Example Request
curl https://there-there.app/api/tickets/01HX9F3K2M... \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json"
Example Response
{
"data": {
"id": 42,
"ulid": "01HX9F3K2M...",
"subject": "Cannot reset my password",
"status": "open",
"channel": { "id": 1, "name": "Support", "type": "email", "color": "#3b82f6" },
"assignee": null,
"contact": {
"id": 12,
"ulid": "01HX9F3K2N...",
"name": "John Doe",
"email": "john@customer.com",
"avatar_url": null
},
"tags": [],
"custom_fields": [
{
"ulid": "01HX9F3K2P...",
"name": "Reported version",
"type": "text",
"value": "1.2.3",
"selected_option_ulids": [],
"options": []
},
{
"ulid": "01HX9F3K2R...",
"name": "Framework",
"type": "single_select",
"value": null,
"selected_option_ulids": ["01HX9F3K2S..."],
"options": [
{
"ulid": "01HX9F3K2S...",
"name": "Laravel",
"color": "#FCA5A5",
"icon": "flame"
},
{ "ulid": "01HX9F3K2T...", "name": "Symfony", "color": null, "icon": null }
]
}
],
"latest_message_preview": "I tried clicking the link but...",
"summary": null,
"created_at": "2025-06-01T10:30:00+00:00",
"updated_at": "2025-06-02T14:15:00+00:00",
"messages": [
{
"id": 101,
"ulid": "01HX9F3K2Q...",
"type": "inbound",
"body_html": "<p>I tried clicking the link but it expired.</p>",
"sender": {
"id": 12,
"type": "contact",
"name": "John Doe",
"email": "john@customer.com",
"avatar_url": null
},
"attachments": [],
"is_forward": false,
"created_at": "2025-06-01T10:30:00+00:00"
}
]
}
}
Activities for a ticket are exposed through a separate endpoint. See List Ticket Activities below.
Update Ticket Status
PUT /api/tickets/{ticket_ulid}/status
{ "status": "closed" }
Valid status values: open, waiting, closed, spam.
Update Ticket Assignee
PUT /api/tickets/{ticket_ulid}/assignee
{ "assignee_ulid": "01hx9f3k2a..." }
Pass null for assignee_ulid to unassign.
Update Ticket Team
PUT /api/tickets/{ticket_ulid}/team
{ "team_ulid": "01hx9g4m3r..." }
Pass null for team_ulid to unassign.
Custom fields
Tickets carry your workspace's custom fields. GET /api/tickets/{ticket_ulid} returns them under custom_fields, and every write response does too.
Listing the field definitions, filtering the ticket list by a field, asking for the values alongside the list, and setting a value are covered in Custom Fields.
List Ticket Activities
Retrieve the activity log for a single ticket (status changes, assignment changes, tag changes, etc.).
GET /api/tickets/{ticket_ulid}/activities
Delete Ticket (admin only)
DELETE /api/tickets/{ticket_ulid}
Permanently deletes the ticket, its messages and drafts. Returns 204 on success.