Browse docs

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.