Tickets API
The Tickets API manages tickets from creation to closure: create, edit and delete, links to problems, changes, assets and KB articles, bulk editing, merging duplicates, activity history, substitutes and a fine-grained permission model.
Endpoints Overview
CRUD & Query
| Method | Endpoint | Description |
|---|---|---|
GET | /api/tickets | List all tickets (with filtering) |
GET | /api/tickets/stats | Statistics (counts per status) |
GET | /api/tickets/substitute-info | Substitute info (users I cover) |
GET | /api/tickets/:id | Get single ticket |
POST | /api/tickets | Create new ticket |
PATCH | /api/tickets/:id | Update ticket |
DELETE | /api/tickets/:id | Delete ticket (soft-delete) |
POST | /api/tickets/:id/restore | Restore from trash (restore + viewDeleted) → 204 |
Trash: The list of deleted tickets runs through ?deleted=1 and requires tickets.viewDeleted. Restoring requires tickets.restore plus tickets.viewDeleted. This applies to users and API keys alike. Deleting is a critical action and is refused while active links exist, including incident links (400 TICKET_HAS_ACTIVE_LINKS). The same lock covers the sub-ticket relationship: a ticket with sub-tickets, and a ticket that is a sub-ticket itself, cannot be deleted (details.links names children resp. parent).
Participant and follow routes check ticket visibility: They check the same visibility as the ticket detail, including mailbox restriction, groups and substitution. On a deleted ticket they answer 404, and the participants of a ticket from an access-restricted mailbox stay hidden without that access — even for roles with global visibility.
Messages & Activity
| Method | Endpoint | Description |
|---|---|---|
POST | /api/tickets/:id/messages | Add message/comment |
Cross-Entity Linking
| Method | Endpoint | Description |
|---|---|---|
POST | /api/tickets/:id/batch-update-links | Batch update all links (problems, changes, assets, KB) |
Advanced Operations
| Method | Endpoint | Description |
|---|---|---|
GET | /api/tickets/:id/merge-preview | Merge preview (what would be transferred) |
POST | /api/tickets/:id/merge | Merge ticket (consolidate duplicates) |
POST | /api/tickets/:id/transfer-attachments | Move attachments from another ticket (sourceTicketId) into this ticket (tickets.editAll) |
PATCH | /api/tickets/:id/mailbox | Change the ticket mailbox (permission tickets.changeMailbox). Rejections carry a named reason: TICKET_NOT_EMAIL_SOURCED, TICKET_ALREADY_IN_MAILBOX, TARGET_MAILBOX_INACTIVE, MAILBOX_NOT_FOUND. |
PATCH | /api/tickets/bulk | Bulk operations (status/assign/…, permission tickets.bulk) |
GET | /api/tickets/:id/suggest-known-errors | Suggest known errors for the ticket (problems.viewOwn) |
POST | /api/tickets/:id/apply-workaround | Apply a workaround from a known error → 204 (problems.viewOwn + tickets.editOwn; unknown problem: 404 PROBLEM_NOT_FOUND) |
Participants & Followers
| Method | Endpoint | Description |
|---|---|---|
GET | /api/tickets/:id/participants | All participants of a ticket → { data }. Per row: id, userId, email, displayName, role, source, notificationsDisabled and user{id,name,email}. |
POST | /api/tickets/:id/participants | Add participant (FOLLOWER, CC, MENTIONED) |
PATCH | /api/tickets/:id/participants/:participantId | Change participant role |
DELETE | /api/tickets/:id/participants/:participantId | Remove participant → 204 (unknown participant id: 404 PARTICIPANT_NOT_FOUND) |
POST | /api/tickets/:id/follow | Follow ticket (current user) → 204, idempotent. Whoever already is the customer or assigned agent, wants to follow a closed ticket, or has no email address gets 400 CANNOT_FOLLOW_TICKET with the reason in details.reason. |
DELETE | /api/tickets/:id/follow | Unfollow ticket (current user) → 204 |
Email Actions
| Method | Endpoint | Description |
|---|---|---|
GET | /api/tickets/:id/email-thread | View email thread of a ticket |
POST | /api/tickets/:id/email-reply | Send email reply (with signature) |
POST | /api/tickets/:id/email-retry/:emailMessageId | Retry failed email |
PATCH | /api/tickets/:id/email-dismiss/:emailMessageId | Dismiss email error |
Sending email requires the status permission: Replying, retrying and dismissing check the same as PATCH /api/tickets/:id: first visibility of the ticket, then the edit permission plus tickets.editStatus or editAll. Reason: a reply automatically moves the ticket to WAITING_CUSTOMER and thereby satisfies the response SLA. A role with editOwn but without editStatus (typical for end users) therefore gets 403. Sending requires a signed-in user; API keys are rejected.
The thread itself stays readable as soon as the ticket is visible (viewOwn is enough) — it returns a data-minimized view: send payload, raw headers, security details, external IDs and an attachment's storage path stay server-side. Files are downloaded through the attachments API.
Categories
| Method | Endpoint | Description |
|---|---|---|
GET | /api/tickets/categories | Get all categories |
GET | /api/tickets/categories/:id/subcategories | Get subcategories |
Reading categories requires a permission: ONE of tickets.create, tickets.viewAll, tickets.viewOwn, settings.manageCategories, settings.editSLA, settings.editGeneral, inboundMailboxes.view or agents.manageGroups is sufficient. A role without any of these permissions gets 403; this mainly affects integration keys with very narrow roles. Creating, changing and deleting categories requires settings.manageCategories. This critical permission also covers categories of other areas.
Email Integration
Tickets can be automatically created and updated via email:
Email-to-Ticket (Inbound)
Incoming emails to configured mailboxes (IMAP or Microsoft Graph API) are automatically converted to tickets. Each mailbox can be individually configured with its own credentials, categories, priorities and access rights:
| Email Field | Ticket Field |
|---|---|
| Subject | title |
| Body (text/HTML) | description |
| From address | customerId (user auto-created if not exists) |
| Attachments | Via unified attachment system (virus scan) |
| - | source = EMAIL |
| - | priority (from mailbox config) |
| - | categoryId (from mailbox config) |
| - | assignedGroupId (from mailbox config) |
Reply-to-Ticket (Thread Matching)
Email replies are automatically matched to the correct ticket via RFC 822 threading:
- Custom Headers:
X-Ticket-ID,X-Ticket-Number - In-Reply-To Header: References previous message ID
- References Chain: All previous message IDs
- Subject Pattern:
[TK-000123],[HD-456789]
Agent Reply via Email
When an agent replies via POST /api/tickets/:id/email-reply, the reply is sent to the customer by email (201):
// Agent reply by email
POST /api/tickets/:id/email-reply
{
"content": "We have identified the issue and are working on a solution...",
"ccAddresses": ["colleague@company.com"],
"includeQuotedReply": true, // ← quotes the last message of the thread (default true)
"attachmentIds": ["b6f1c1e2-9a4d-4c3e-8f7a-2d5e6c7b8a90"] // optional, max. 10
}
// The email contains:
// - Subject: "Re: [TK-000123] Laptop won't start"
// - From: support@company.com (mailbox config)
// - To: customer@example.com
// - Body: agent reply + quoted last message of the thread
// - Headers: In-Reply-To, References (for threading)
Quote and source mailbox: The quote takes the last dispatched message of the thread — the one from the customer or the previous reply of the agent. Failed and dismissed emails are never quoted. The quote header is written in the language of the recipient and carries a formatted date; subject and threading headers follow the last incoming email. Sending requires an active source mailbox: if it is deactivated, the reply is rejected with 400 SOURCE_MAILBOX_INACTIVE and no outgoing entry appears in the thread.
📧 Detailed Email Integration: See Integrations & Notifications → for IMAP/Graph API configuration, individual mailboxes, email signatures, sender policies, mailbox access control, bounce detection and thread matching logic.
💬 Canned responses: In the reply composer agents insert predefined response templates (multilingual, scope PERSONAL/AGENT_GROUP/ORGANIZATION). See Response Templates API →.
Ticket Status
| Status | Description |
|---|---|
OPEN | Open (new/unhandled) |
IN_PROGRESS | In progress |
WAITING_CUSTOMER | Waiting for customer (active, SLA running); may be auto-resolved after inactivity (WC-auto-resolve, opt-in) |
WAITING_SUPPORT | Waiting for support/vendor (active, SLA running) |
ON_HOLD | On hold (inactive, SLA paused) — with reminder via holdReminderAt |
RESOLVED | Resolved, awaiting customer confirmation/auto-close |
CLOSED | Closed & completed |
SPAM | Marked as spam |
Permissions
| Permission | Description |
|---|---|
tickets.viewAll | View all tickets |
tickets.viewOwn | View own tickets (as customer, assignee, member of the assigned group, substitute or participant) |
tickets.create | Create tickets |
tickets.createForOthers | Create tickets for other users |
tickets.editStatus | Change status |
tickets.editPriority | Change priority |
tickets.editCategory | Change the category, also when only the subcategory (subcategoryId) changes |
tickets.assign | Assign/reassign tickets |
tickets.editOwn | Edit own/assigned tickets (owner scope) |
tickets.editAll | Edit all fields (incl. merge, links) and, as the only permission, changing the customer (customerId) |
tickets.reopen | Reopen closed/SPAM tickets (CLOSED/SPAM → OPEN; dedicated permission, reason required; editAll does not include it) |
tickets.reopenOverride | Bypass reopen window/limit (not the reason requirement) |
tickets.bulk | Bulk operations (status/assign/…) |
tickets.changeMailbox | Move ticket to another mailbox |
tickets.linkToTickets | Sub-tickets: create with a parent ticket, subordinate, release the relationship, and the candidate list |
tickets.viewInternal | View internal notes (agent-only) |
tickets.delete | Delete tickets (critical, audit log) |
tickets.restore | Restore deleted tickets |
tickets.viewDeleted | View deleted tickets |
Visibility & Access (Mailbox + Agent Groups)
Which tickets a user sees in GET /api/tickets depends, besides tickets.viewAll/viewOwn, on three further factors:
- Mailbox access (accessRestricted): Tickets of an access-restricted mailbox are only visible to explicitly authorized users/roles/agent groups (MailboxAccess list with separate canViewTickets / canBeAssigned flags). Even tickets.viewAll is constrained by a restricted mailbox.
- Agent group scope: The visibility check considers the user's active group memberships (assignedGroupId ∈ own groups) plus reporter/assignee and substitution. So an agent sees their group's queue without global viewAll.
- Substitution (substitute): During an absence the substitute sees/edits the covered user's tickets; actions are logged with actingAs: SUBSTITUTE.
Concept/configuration: see Integrations (mailbox access rights) and User Management & RBAC.
Resolution Codes (on resolve)
When setting RESOLVED/CLOSED a standardized resolutionCode can be supplied (PATCH /api/tickets/:id), together with resolutionNote (customer-visible), resolutionInternalComment (agents only) and optional resolutionCcAddresses. The available codes are maintained centrally. For an email ticket the resolution goes out as a threaded reply to the customer and CC; if the source mailbox is deactivated the ticket is still resolved and the resolution email is omitted. Resolution Codes API →
Sub-Tickets
A ticket can be split into sub-tickets when one request covers several partial tasks — an onboarding requiring hardware, accounts and training, for instance. The relationship is exactly one level deep: a sub-ticket takes no sub-tickets of its own, and a ticket with sub-tickets cannot be subordinated to another one. That keeps the list flat and never counts a request twice.
Creating and Linking
# Create a sub-ticket directly under a parent (permission tickets.linkToTickets)
POST /api/tickets
{ "title": "Order laptop", "customerId": "clx...", "parentTicketId": "clx-parent-ticket-id" }
# → 201, response carries "parentTicket": { "id": "clx...", "ticketNumber": "TKT-2026-000042" }
# Subordinate an existing ticket / release it again
POST /api/linking/tickets/:parentId/link-child-ticket { "childTicketId": "clx..." } # → 204
DELETE /api/linking/tickets/:parentId/link-child-ticket/clx-child-ticket-id # → 204
# Candidates for the subordinate dialog (permission tickets.linkToTickets)
GET /api/tickets?forSubTicketOf=clx-parent-ticket-id&q=laptop
parentTicketId— optional on create. Link and ticket come into being together: if the link fails, the ticket is not created either — no orphan child is left behind. The field requires tickets.linkToTickets and a logged-in user; an API key cannot create a sub-ticket.parentTicket— on the child: id and ticketNumber of the parent (without title), in the list as well as the detail.childTicketCounts— on the parent: { total, open }. Open means: not RESOLVED, CLOSED or SPAM.forSubTicketOf— returns exactly the tickets that can be hung under the named parent: not the parent itself, no ticket with sub-tickets of its own, none with an incident, problem or change link, and none already hanging elsewhere (its own children stay in the set — the dialog is a set editor). Without tickets.linkToTickets the list answers 403 — the structure of other people's tickets is not queryable without that permission. Combinable with q and the other filters.- The rules and rejections of linking (one level, one parent, no process links on the child, open parent) are documented in the Entity Linking API and apply to creating with parentTicketId in the same way.
Visibility of the Structure
Parent and child can belong to different people — up to a third party. Number and title of the other side therefore do not belong in the customer view: the sub-ticket history, the children list, the SLA pause reason and the counters are visible only to holders of tickets.viewInternal. Without that permission the response carries parentTicket: null and childTicketCounts 0/0, the children list stays empty, and the history entries about the parent-child relationship do not appear. Redacted rather than refused: the ticket itself stays fully readable for its customer.
Resolving a Parent Ticket
A parent ticket with open sub-tickets goes neither to RESOLVED nor to SPAM: the API answers 409 TICKET_HAS_OPEN_CHILDREN and names the tickets concerned in details.openChildren, with their numbers, so the resolve dialog can list them. CLOSED needs no lock of its own — it is only reachable via RESOLVED. The lock applies to every path that sets the status, bulk editing included.
Automatic paths do not fail on this, they leave the ticket out: auto-resolve after customer inactivity, the bulk resolution of linked tickets via an incident or problem, and the cascading close when a problem is completed all skip such a parent ticket and count it as skipped — with a named reason, so the UI can show it.
List and Counting
- The ticket list stays flat. A sub-ticket carries a marker next to its number; the optional column "sub-ticket relation" shows the parent number on the child and the number of children including open ones on the parent. The column is off by default and not sortable, because it carries two different statements.
- By default sub-tickets are counted. Whoever wants to see requests only — an agent queue without double counting, say — hides them via the named filter ?fn.hideSubTickets; it can be stored in a saved view like any other filter. The status tiles above the list follow it.
- GET /api/tickets/stats knows the same distinction via excludeSubTickets=true; without the parameter sub-tickets are counted.
In the UI the actions "create sub-ticket" and "subordinate an existing ticket" sit on the ticket detail; the sub-tickets themselves appear in the ticket's links area, and on the child as its parent ticket.
API Examples
Create Ticket
POST /api/tickets
Category and subcategory are set exclusively via categoryId and subcategoryId; the API does not accept names. categoryId: null clears the category; the response additionally contains the name. The customer of a ticket must have an active account: no ticket is created for or moved to an archived or deactivated account (400 TICKET_CUSTOMER_NOT_ACTIVE). Existing tickets stay editable even if their customer is blocked later; customer.accountStatus (active | inactive | archived) shows the account state in the list and the detail, the UI shows only the deviation. PATCH accepts known fields only; an unknown key returns 400 VALIDATION_ERROR. Whoever sends a ticket response back must first reduce it to the editable fields.
{
"title": "Laptop no longer starts",
"description": "After a Windows update the laptop only boots to the boot screen. Error message: 'INACCESSIBLE_BOOT_DEVICE'",
"priority": "HIGH",
"categoryId": "clx...",
"customerId": "clx...",
"assignedAgentId": "clx...",
"assignedGroupId": "clx...",
"metadata": {
"location": "Frankfurt Office, Desk 42",
"deviceSerial": "SN123456789",
"osVersion": "Windows 11 22H2"
},
"tags": ["hardware", "laptop", "windows-update"]
}
Response (201 Created)
{
"id": "clx...",
"ticketNumber": "TKT-2026-000042",
"title": "Laptop no longer starts",
"description": "After a Windows update the laptop only boots to the boot screen...",
"status": "OPEN",
"priority": "HIGH",
"category": {
"id": "clx...",
"name": "Hardware",
"color": "#ef4444"
},
"customer": {
"id": "clx...",
"name": "Max Mustermann",
"email": "max@company.com",
"accountStatus": "active"
},
"assignedAgent": {
"id": "clx...",
"userId": "clx...",
"user": {
"name": "IT Support Agent",
"email": "support@company.com"
}
},
"assignedGroup": {
"id": "clx...",
"name": "IT Support Level 1"
},
"createdAt": "2026-01-27T10:30:00Z",
"updatedAt": "2026-01-27T10:30:00Z",
"version": 1
}
Two create modes (source): The create schema branches on the source field (default WEB): WEB = classic portal create (customerId required; optional formId/customFormData for CustomForm-driven tickets). EMAIL = agent-initiated outbound — the agent picks a source mailbox and the description becomes the email body. The mobile app and workflow engine set no source and stay on WEB.
📝 formId/customFormData reference a CustomForm of category TICKET. Available forms for the create flow come from GET /api/forms/available?category=TICKET. The form field rule is enforced server-side: violations are 400 FORM_SUBMISSION_INVALID (findings in details.issues), an unknown form 404 FORM_NOT_FOUND, a disabled or archived one 400 FORM_NOT_AVAILABLE. On PATCH it applies to the visible custom fields of the RESULTING state — without customFormData in the payload nothing is checked, so a status or priority PATCH stays free. The form of an existing ticket cannot be switched via PATCH. Structure, fields and visibility: see Custom Forms API →.
👁 Custom fields are visibility-filtered: detail and mutation responses carry only the fields the viewer may see (hiddenForEndUsers, visibleToRoles) — in customFormData as well as in the field trail of the history. The list does not contain formId and customFormData. The detail additionally returns form: the ticket form in the same user-filtered shape as /forms/available, so a second call is unnecessary. Custom fields of existing tickets stay visible even if their form has since been disabled.
Agent + group in parallel
assignedAgentId and assignedGroupId exist independently side by side — a ticket can be assigned to a group (queue) and a specific agent at the same time. autoAssignAgent (on update) requires an assignedGroupId to be set. As assignedAgentId the API accepts the agent USER ID (the canonical input, which the agent pickers also provide). An agent profile ID is accepted as well.
Changing a ticket's customer requires tickets.editAll; tickets.editOwn is not enough, because visibility and notifications move to another person along with the customer.
Create Ticket via Email (source=EMAIL)
POST /api/tickets
{
"source": "EMAIL",
"sourceMailboxId": "clx-mailbox-id",
"title": "Re: Onboarding new laptop",
"description": "Hello, please find the setup steps attached ...",
"externalCustomer": { "email": "customer@external.com", "name": "Max External" },
"ccAddresses": ["colleague@company.com"],
"priority": "HIGH",
"assignedGroupId": "clx-group-id",
"sendInitialEmail": true,
"includeSignature": true
}
Rules for source=EMAIL: sourceMailboxId required; XOR customerId OR externalCustomer.email (exactly one); description must be non-empty (= mail body); formId/customFormData and attachments are NOT allowed here (the agent attaches files via reply after the ticket exists). ccAddresses and includeSignature apply to EMAIL only.
Update Ticket
PATCH /api/tickets/:id
{
"status": "IN_PROGRESS",
"assignedAgentId": "clx...",
"priority": "MEDIUM",
"resolution": "Diagnosing boot issue. Checking Windows event logs.",
"version": 1
}
Response (200 OK)
{
"id": "clx...",
"ticketNumber": "TKT-2026-000042",
"status": "IN_PROGRESS",
"priority": "MEDIUM",
"resolution": "Diagnosing boot issue. Checking Windows event logs.",
"version": 2,
"updatedAt": "2026-01-27T11:15:00Z"
}
Optimistic Locking: The version field prevents concurrent edits from overwriting each other. On a conflict the API answers 409 TICKET_VERSION_CONFLICT; the details name the expected and the actual version.
Add Message (Public)
POST /api/tickets/:id/messages
{
"content": "Update: Laptop restarted in Safe Mode. Windows repair is running.",
"isInternal": false,
"type": "message"
}
Add Internal Note (Agent-Only)
{
"content": "User used admin rights for the Windows update. Backup is missing.",
"isInternal": true,
"type": "message"
}
Internal Notes: Only visible to users with tickets.viewInternal permission. Customer does NOT see these notes.
Cross-Entity Linking (Batch Update)
POST /api/tickets/:id/batch-update-links
{
"problems": {
"add": ["clx-problem-123"],
"remove": []
},
"changes": {
"add": ["clx-change-456"],
"remove": []
},
"assets": {
"add": ["clx-asset-789", "clx-asset-012"],
"remove": []
},
"kbArticles": {
"add": ["clx-kb-345"],
"remove": []
}
}
Response
{
"success": true,
"problems": { "added": 1, "removed": 0 },
"changes": { "added": 1, "removed": 0 },
"assets": { "added": 2, "removed": 0 },
"kbArticles": { "added": 1, "removed": 0 }
}
Automatically:
- Activity entries in ticket timeline ("Linked to problem PRB-123")
- Activity entries in problem timeline ("Ticket TKT-42 linked")
- Same for changes, assets, KB articles
- All changes in one transaction, all or nothing
- Audit entry (LINKS_UPDATED)
Get Ticket with All Links
GET /api/tickets/:id
Response (with Links)
{
"id": "clx...",
"ticketNumber": "TKT-2026-000042",
"title": "Laptop no longer starts",
"status": "IN_PROGRESS",
"linkedProblems": [
{
"id": "clx...",
"problemNumber": "PRB-2026-000015",
"title": "Windows Update causes boot failures on Dell XPS series",
"status": "INVESTIGATING"
}
],
"linkedChanges": [
{
"id": "clx...",
"number": "CHG-2026-000089",
"title": "Rollback Windows Update KB5034441",
"status": "IN_PROGRESS"
}
],
"linkedAssets": [
{
"id": "clx...",
"assetTag": "00042",
"name": "Dell XPS 15",
"serialNumber": "SN123456789",
"status": "DEPLOYED"
},
{
"id": "clx...",
"assetTag": "00043",
"name": "Dell Monitor 27\"",
"status": "DEPLOYED"
}
],
"linkedKBArticles": [
{
"id": "clx...",
"title": "How to boot Dell XPS into Safe Mode",
"category": "Troubleshooting"
}
],
"messages": [
{
"id": "clx...",
"content": "Laptop restarted in Safe Mode...",
"isInternal": false,
"author": { "name": "IT Support Agent" },
"createdAt": "2026-01-27T11:30:00Z"
},
{
"id": "clx...",
"content": "User used admin rights...",
"isInternal": true,
"author": { "name": "IT Support Agent" },
"createdAt": "2026-01-27T11:35:00Z"
}
]
}
Merge Ticket (Duplicates)
POST /api/tickets/:id/merge
{
"sourceTicketId": "TKT-2026-000043",
"direction": "source-to-target"
}
Response
{
"primaryTicket": { "id": "clx...", "ticketNumber": "TKT-2026-000042", "title": "Laptop no longer starts" },
"mergedTicket": { "id": "clx...", "ticketNumber": "TKT-2026-000043", "title": "Dell XPS won't boot" },
"stats": {
"messagesCopied": 3,
"attachmentsTransferred": 2,
"tagsMerged": 2,
"problemsLinked": 0,
"changesLinked": 1,
"emailsTransferred": 4,
"sourceChanged": false,
"mailboxChanged": false
}
}
Merge error codes: CANNOT_MERGE_SELF, TICKET_CANNOT_RECEIVE_MERGES, TICKET_CANNOT_BE_MERGED (400 each); if a ticket disappears mid-operation the API answers 404. Two upfront checks answer 400 with details: CONFIRMATION_REQUIRED (confirmation needed, details.warnings — confirm with confirmWarnings: true in the body) and SOURCE_SELECTION_REQUIRED (the two tickets have different sources — details.primarySource and details.mergedSource name them).
If the source selection keeps "web", the server also withdraws the email mode — otherwise a ticket without a mailbox would remain in email mode, with replies going nowhere. A ticket's merge chain comes from GET /api/tickets/:id/merge-graph; candidates are searched server-side via the list using ?forMerge=true&q=….
Flow (atomic):
- All messages copied from source → target
- All attachments transferred from source → target
- All links (problems, changes, assets, KB) merged
- Tags merged (deduplicated)
- Sub-ticket structure carried over (children move, the parent relationship is inherited or dropped)
- Source ticket set to CLOSED
- Audit trail created
Merging and Sub-Tickets
A merge closes one ticket (the merged one) while the other stays open and receives (the target). If either side carries a sub-ticket relationship, a fixed matrix decides — the same one in the preview as in the operation itself, so the dialog never announces anything other than what happens. The three outcomes that change something come as a warning requiring confirmation (details.warnings) and additionally as structure in the preview: childrenToRehang, parentInherited and relationDropped.
| Merged ticket | Target ticket | Result |
|---|---|---|
| no relationship | any | Nothing additional. |
| parent ticket | no relationship, or a parent itself | The sub-tickets move to the target (CHILDREN_REHUNG). |
| sub-ticket of X | X itself, or a sibling | The parent relationship is dropped before closing (CHILD_RELATION_DROPPED). |
| sub-ticket of X | no relationship | The target becomes a sub-ticket of X (PARENT_INHERITED). |
Four constellations would break the single level or would have to guess an assignment; they are refused with 409 and a named code — in the preview as well as on execution, and a confirmation does not help there:
| Error Code | HTTP | When |
|---|---|---|
MERGE_PARENT_INTO_OWN_CHILD | 409 | A parent ticket is to be merged into its own sub-ticket. |
MERGE_WOULD_NEST | 409 | A second level would arise: a parent into a sub-ticket, or a sub-ticket into a ticket that carries sub-tickets itself (details.reason distinguishes the two). |
MERGE_DIFFERENT_PARENTS | 409 | Both sides are sub-tickets, but under different parents — which one should apply is not a decision for the server. |
MERGE_TARGET_HAS_PROCESS_LINKS | 409 | The target would inherit the parent relationship but carries incident, problem or change links — which a sub-ticket must not have. |
MERGE_STRUCTURE_CHANGED | 409 | Between preview and execution somebody changed the structure of either side. Fetch the preview again and confirm anew. |
Get Statistics
GET /api/tickets/stats?scope=relevant
Response
{
"total": 1234,
"byStatus": {
"OPEN": 45,
"IN_PROGRESS": 234,
"WAITING_CUSTOMER": 30,
"WAITING_SUPPORT": 18,
"ON_HOLD": 12,
"RESOLVED": 89,
"CLOSED": 731,
"SPAM": 5
},
"byPriority": {
"LOW": 234,
"MEDIUM": 567,
"HIGH": 345,
"URGENT": 120,
"CRITICAL": 88
},
"byCategory": [
{ "categoryId": "clx...", "name": "Hardware", "count": 456 },
{ "categoryId": "clx...", "name": "Software", "count": 345 },
{ "categoryId": "clx...", "name": "Network", "count": 234 }
],
"unassigned": 45,
"overdue": 12,
"avgResolutionTimeHours": 24.5
}
Substitute Info
GET /api/tickets/substitute-info
Response
{
"usersImCovering": [
{
"userId": "clx-absent-agent",
"userName": "John Doe",
"email": "john@company.com",
"absenceStart": "2026-01-20",
"absenceEnd": "2026-02-03",
"ticketCounts": {
"total": 23,
"OPEN": 3,
"IN_PROGRESS": 12,
"WAITING_CUSTOMER": 8
}
}
]
}
The substitute system allows an agent to work on tickets of an absent colleague. All actions are logged with "actingAs: SUBSTITUTE".
Filtering & Query
Filter Parameters
| Parameter | Description |
|---|---|
f.status | OPEN, IN_PROGRESS, WAITING_CUSTOMER, WAITING_SUPPORT, ON_HOLD, RESOLVED, CLOSED, SPAM |
f.priority | LOW, MEDIUM, HIGH, URGENT, CRITICAL |
f.source | WEB, EMAIL, API |
f.ticketNumber / f.title | Text filters |
f.categoryId / f.customerId / f.assignedUserId / f.assignedGroupId / f.sourceMailboxId | Assignment filters |
f.createdAt / f.updatedAt / f.resolvedAt / f.closedAt | Time filters |
f.slaStatus | SLA state (see below) |
q | Search across number, title, description |
page / per / sort | Paging (per defaults to 50, max 200); sort=<field>:asc|desc, plus sort=sla (time left until the resolution deadline) |
scope | relevant, all, substitute |
deleted=1 | Trash: ONLY deleted tickets (requires tickets.viewDeleted; the value 1 only) |
includeDeleted=true | Mixed list incl. deleted ones (requires tickets.viewDeleted) |
following / involved | Tickets a user follows resp. participates in |
forMerge=true / excludeTicketId | Merge candidates (excludes CLOSED/SPAM) |
forSubTicketOf | Candidates to subordinate under the named parent ticket (requires tickets.linkToTickets) |
fn.hideSubTickets | Named filter: hides sub-tickets, so only requests remain. Without it sub-tickets are counted. |
cursor | Cursor pagination (for large data sets) |
Strict parameter validation: An unknown scope value returns 400. deleted and includeDeleted accept only the listed values; anything else returns 400. Status and priority values must be sent in upper case; lower-case or hyphenated values return 400. This applies to filters, bulk operations and every write body.
Filtering by SLA status
The ticket list can be filtered by SLA state — via the list API filter syntax (f. prefix). The filter uses exactly the same source as the list's SLA column (the monitor-maintained status, ~2 min fresh), so column and filter can never contradict each other.
# Alles, was brennt
GET /api/tickets?f.slaStatus=in:BREACH,CRITICAL
# Nur pausierte SLA-Uhren
GET /api/tickets?f.slaStatus=PAUSED
# Tickets ohne aktives SLA-Tracking
GET /api/tickets?f.slaStatus=isNull
| Value | Meaning |
|---|---|
PAUSED | The clock is stopped (ON_HOLD or open incident link). Exclusive — a paused ticket does NOT additionally show up under its frozen previous status. |
OK | Running within target — includes trackings the monitor has not calculated yet (same convention as the dashboard). |
WARNING · BREACH · CRITICAL | Running, non-paused trackings in the respective state |
isNull / isNotNull | Ticket WITHOUT resp. WITH an active SLA tracking |
Deliberate limitations: there is no neq/notIn — "not BREACH" would be ambiguous, since it would be unclear whether tickets without any SLA tracking belong to it; isNull/isNotNull covers that. And CANCELLED is missing: a cancelled tracking is a dashboard topic, not a working state of a ticket list. The filter also applies to saved views including their result counts.
Example Queries
# Open and in-progress tickets of my group
GET /api/tickets?f.status=in:OPEN,IN_PROGRESS&f.assignedGroupId=clx...
# All HIGH-priority tickets, oldest first
GET /api/tickets?f.priority=HIGH&f.status=OPEN&sort=createdAt:asc
# Tickets relevant to me (mine plus my groups)
GET /api/tickets?scope=relevant
# Full-text search
GET /api/tickets?q=laptop+boot+problem
# Tickets of an absent colleague (substitute view)
GET /api/tickets?scope=substitute&f.assignedUserId=clx-absent-agent
# Trash (requires tickets.viewDeleted)
GET /api/tickets?deleted=1
Advanced Features
Categories & Sub-Categories
GET /api/tickets/categories
Response (Hierarchical)
{
"data": [
{
"id": "clx...",
"name": "Hardware",
"color": "#ef4444",
"icon": "laptop",
"isActive": true,
"ticketCount": 456,
"subcategories": [
{
"id": "clx...",
"name": "Laptop",
"ticketCount": 234
},
{
"id": "clx...",
"name": "Desktop",
"ticketCount": 123
},
{
"id": "clx...",
"name": "Peripherals",
"ticketCount": 99
}
]
},
{
"id": "clx...",
"name": "Software",
"color": "#8b5cf6",
"subcategories": [...]
}
]
}
Attachments
Tickets use the Unified Attachment System. See: Attachments & File Settings API →
# Upload file to ticket
POST /api/attachments/TICKET/:ticketId
# Get all attachments of a ticket
GET /api/attachments/TICKET/:ticketId
# Download file
GET /api/attachments/:id/download
Transfer Attachments
POST /api/tickets/:id/transfer-attachments
{
"sourceTicketId": "clx-source-ticket",
"attachmentIds": ["clx-att-1", "clx-att-2"]
}
Response
{
"transferred": 2
}
Error Handling
| Error Code | HTTP Status | Description |
|---|---|---|
TICKET_NOT_FOUND | 404 | Ticket ID does not exist |
CANNOT_EDIT_TICKET | 403 | No edit permission for this ticket |
CANNOT_CREATE_FOR_OTHERS | 403 | createForOthers permission missing |
CANNOT_VIEW_INTERNAL_NOTES | 403 | viewInternal permission missing |
TICKET_VERSION_CONFLICT | 409 | State changed in the meantime (optimistic locking) |
TICKET_HAS_ACTIVE_LINKS | 400 | Ticket has active links (problems, changes, incidents, assets, KB, sub-tickets or a parent ticket) — remove them first |
TICKET_HAS_OPEN_CHILDREN | 409 | A parent ticket with open sub-tickets cannot be set to RESOLVED or SPAM (details.openChildren names them) |
CUSTOMER_NOT_FOUND | 404 | Customer ID does not exist |
SOURCE_MAILBOX_INACTIVE | 400 | The source mailbox of the ticket is deactivated — no email goes out |
TICKET_HAS_NO_MAILBOX | 400 | The ticket has no mailbox, so there is no sender |
TICKET_IS_SPAM | 400 | No reply is sent on a ticket marked as spam |
EMAIL_NOT_FAILED | 409 | The email is not in a failed state or belongs to another ticket — both cases get the same answer, so it does not reveal whether the ID exists elsewhere |
EMAIL_RETRY_DATA_MISSING | 400 | No stored send record — write a new reply instead |
EMAIL_RETRY_LIMIT_EXCEEDED | 409 | Limit of manual retries reached (5) |
Use Cases
Use Case 1: Link Ticket with Problem & Change
// Scenario: laptop boot problem is a known error
// 1. Problem PRB-123 exists: "Windows Update KB5034441 breaks Dell XPS boot"
// 2. Change CHG-456 exists: "Rollback KB5034441"
// 3. Ticket TKT-42: "Laptop won't start"
// Link:
POST /api/tickets/TKT-42/batch-update-links
{
"problems": { "add": ["PRB-123"] },
"changes": { "add": ["CHG-456"] }
}
// Result:
// - Ticket timeline: "Linked to problem PRB-123"
// - Ticket timeline: "Linked to change CHG-456"
// - Problem timeline: "Ticket TKT-42 linked"
// - Change activity: "Ticket TKT-42 linked"
// Advantages:
// - Customer sees in TKT-42: "Known problem, change in progress"
// - Problem PRB-123 shows all affected tickets
// - Change CHG-456 shows all affected tickets
// - CMDB tracking complete
Use Case 2: Merge Duplicates
// Scenario: 2 users report the same problem
// TKT-42: "Laptop won't start" (John Doe, 10:30)
// TKT-43: "Dell XPS won't boot" (Jane Smith, 10:45)
// Merge:
POST /api/tickets/TKT-42/merge
{
"sourceTicketId": "TKT-43",
"direction": "source-to-target"
}
// Result:
// TKT-42:
// - Now has messages from both users
// - Has attachments from both
// - Both users are in CC
// TKT-43:
// - Status = CLOSED
// - Resolution = "Merged into TKT-42"
// - Reference to TKT-42 in timeline
Use Case 3: Link Asset to Ticket
// Scenario: laptop problem, 2 assets affected
// - Dell XPS 15 Laptop (asset tag: 00042)
// - Dell Monitor 27" (asset tag: 00043)
POST /api/tickets/TKT-42/batch-update-links
{
"assets": {
"add": ["clx-laptop-id", "clx-monitor-id"]
}
}
// Advantages:
// - Asset history shows all tickets
// - On asset checkout: visible whether open tickets exist
// - Reporting: which assets have frequent problems?
Best Practices
💡 Tips
1. Cross-Entity Linking
- • Use batch-update-links for atomic updates (not individual API calls)
- • Link to problems for known errors
- • Link to changes when a planned change is meant to remove the cause
- • Link to assets for hardware tracking
2. Permissions
- • Customers: viewOwn, create (can view/create own tickets)
- • Agents: viewAll, editStatus, editPriority, assign, viewInternal
- • Admins: editAll, delete, restore (critical operations)
- • Substitute system enabled: viewOwn allows access to absent colleague's tickets
3. Performance
- • Use cursor pagination for > 10,000 tickets
- • Use filters to reduce data volume
- • scope=relevant for dashboard views (faster than the full list)
4. Workflow Integration
- • Workflow actions can create tickets (create_ticket)
- • Workflow actions can update tickets (update_ticket)
- • Workflow actions can add comments (add_ticket_comment)
- • Workflows can be triggered via ticket creation
Ticket Relationships
Relationships: Ticket (1:n) → messages & activityTicket (1:n) → sub-tickets (exactly one level)Ticket ↔ Problem (n:m) Ticket ↔ Change (n:m) Ticket ↔ Asset (n:m) Ticket ↔ KB article (n:m) Ticket (1:n) → attachments (via the Attachments API)Ticket (1:1) → SLA tracking (created automatically) Activity entries on both sides: Ticket link to problem: ├─ Ticket timeline: "Linked to problem PRB-123" └─ Problem timeline: "Ticket TKT-42 linked" Ticket link to change: ├─ Ticket timeline: "Linked to change CHG-456" └─ Change activity: "Ticket TKT-42 linked" Ticket link to asset: └─ Ticket timeline: "Linked to asset #00042"
Note: Tickets are the central entry point for end users. They can be linked to problems (root cause), changes (remediation) and assets (affected hardware) for full CMDB integration. Linking is handled centrally via the Entity Linking API. Reopen paths (manual/email/comment), merge redirect and auto-close are documented under Reopen & Lifecycle.