Cross-ticket responses query API

This article and the rest of the API documentation in this section are written for a technical audience — integrators and developers connecting external systems to Tickiti. Familiarity with HTTP, REST, JSON and bearer-token authentication is assumed.

This endpoint returns responses drawn from across every ticket the token can see — the bulk counterpart to List responses, which works one ticket at a time. Filter by a created-at window, queue, author, the internal/staff flags, attachments, a set of tickets or response ids, and page through the matches with a keyset cursor — or ask only for the number of matches, or tallies grouped by a column. Use it to pull all activity in a date range without walking tickets one by one: to feed an external analytics store, build a digest, size a population before reading it, or reconcile an audit log.

Endpoint

POST /api/v1/tickets/responses/query

Headers

  1. Authorization: Bearer YOUR_TOKEN — required. Token must have the tickets:read ability. A tickets:write token also works (write implies read).
  2. Content-Type: application/json — required. No Idempotency-Key is needed (this is a read).

Body

Every field is optional. With an empty body the endpoint returns the first page of responses across all tickets you can see, oldest first.

Filters (all combine with AND):

  1. created_from — lower bound on a response’s creation time. A bare YYYY-MM-DD means that day from 00:00:00; a full YYYY-MM-DD HH:MM:SS is used as given. Compared against created_at as stored (UTC wall-clock).
  2. created_to — upper bound. A bare YYYY-MM-DD means that day to 23:59:59.
  3. is_internal — true for internal staff notes only, false for public responses only. Omit to include both.
  4. staff_response — true for staff-authored responses only, false for customer responses only. Omit for either.
  5. queue — array of queue names; restrict to responses on tickets in those queues.
  6. queue_id — array of queue ids; the same restriction by id.
  7. ticket_number — array of 6-digit ticket numbers (as strings); restrict to responses on those tickets.
  8. response_id — array of response ids; return just those responses.
  9. created_by_email — only responses written by this address (exact match).
  10. has_attachments — true for responses with at least one attachment, false for responses with none.
  11. is_empty — true for responses with no message body (audit entries, attachment-only replies), false for responses that have one.
  12. include_deleted — true to include soft-deleted responses. Defaults to false.

Shaping the result:

  1. fields — array naming the fields to return; see Choosing fields.
  2. metadata_only — true to drop html, plain_text and attachments from every row.
  3. count_only — true to return only the number of matching responses, as { "count": n }.
  4. group_by — one of ticket_number, created_by_email, queue, is_internal, staff_response or day, to return tallies instead of rows; see Counting and grouping.
  5. limit — page size. Defaults to 200, maximum 1000.
  6. cursor — a { "created_at": ..., "id": ... } object copied verbatim from a previous page’s next_cursor. Omit it for the first page. See Paging below.

What you can see

Results obey the same visibility rule as the staff interface: a response is included only if its ticket sits in a queue the acting user can view, that user is a participant on the ticket, or the ticket is assigned to a team the user belongs to. A non-staff token owner is refused with 403. The filters narrow within that visible set — they can never widen it, so a token cannot read responses its owner could not read on screen.

Choosing fields

Each row can carry html, plain_text, attachments, is_internal, is_pinned, staff_response, created_by_email, created_at, updated_at, is_email_sourced, audit and is_audit_only, plus response_id and ticket_number, which are always present. fields may name any of these:

  1. Omitted — every field.
  2. Only body fields (html, plain_text, attachments) — all the metadata plus the body fields named. "fields": [] is a metadata-only pull.
  3. Any metadata field named — exactly the named fields, plus response_id and ticket_number. ["created_by_email", "created_at"] returns four keys per row.

A field that is not returned is absent from each row rather than null. Body fields that are not requested are skipped server-side, which makes large pulls much cheaper. metadata_only removes the three body fields from whatever fields selects (from every field when fields is omitted).

Example

Pull the metadata and attachment lists for every response created in the Sales queue over a two-day window, dropping the bodies:

curl -X POST https://support.sole-provider.example/api/v1/tickets/responses/query \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "created_from": "2026-05-01",
    "created_to": "2026-05-02",
    "queue": ["Sales"],
    "fields": ["attachments"],
    "limit": 200
  }'

Successful response

A responses array, ordered oldest to newest by created_at (ties broken by id), plus the next_cursor to fetch the following page and the count on this page. Each row carries the ticket it belongs to, so you can group responses back onto their tickets. audit and is_audit_only have the same meaning as in Show ticket.

{
  "ok": true,
  "data": {
    "responses": [
      {
        "response_id": "41",
        "ticket_number": "220009",
        "is_internal": true,
        "is_pinned": false,
        "staff_response": true,
        "created_by_email": "[email protected]",
        "created_at": "2026-05-02T10:05:11+00:00",
        "updated_at": "2026-05-02T10:05:11+00:00",
        "is_email_sourced": false,
        "audit": null,
        "is_audit_only": false,
        "html": "<p>Internal note: replacement authorised.</p>",
        "plain_text": "Internal note: replacement authorised.",
        "attachments": []
      }
    ],
    "next_cursor": { "created_at": "2026-05-02 10:05:11", "id": 41 },
    "count": 1
  }
}

Counting and grouping

To size a result before reading it, send the same filters with "count_only": true:

{ "ok": true, "data": { "count": 19 } }

With group_by the endpoint tallies the matching responses by that column instead of returning rows. day groups by the UTC date of created_at; queue by queue name; is_internal and staff_response by their value (1 or 0). Groups are ordered largest first, up to 1000 groups, and total is the sum of their counts:

{
  "ok": true,
  "data": {
    "group_by": "created_by_email",
    "groups": [
      { "value": "[email protected]", "count": 12 },
      { "value": "[email protected]", "count": 7 }
    ],
    "total": 19
  }
}

Both use the filters and ignore limit, cursor and fields. When both are sent, count_only applies.

Paging

Paging is keyset-based, not offset-based, so it stays correct even as new responses arrive mid-walk. To read the whole result set: call the endpoint, process data.responses, then if data.next_cursor is not null call again with that object as cursor. Stop when next_cursor comes back null. Keep the other filters identical across pages.

Error responses

  1. 422 validation_failed — a field has the wrong type, fields contains a name not listed in Choosing fields (the message lists the valid names), group_by is not one of its values, limit is out of range, or a supplied cursor is missing its created_at or id.
  2. 401 unauthenticated — bad or missing token.
  3. 403 forbidden — token lacks the tickets:read (or tickets:write) ability, or the token owner is not a staff account.

Where to go next

  1. List responses API — the single-ticket version, when you already know the ticket.
  2. Download attachment API — fetch the bytes of an attachment listed on a response.
  3. Ticket operations API — read a single response by id, move responses between tickets, or edit and delete them.
  4. MCP server — reach this endpoint from an AI assistant via the query_responses and export_responses tools.