Skip to content

REST API Ticket Related Developer Guide

Ed Mozley edited this page Nov 13, 2026 · 1 revision

πŸ”— A ticket's related block and attachments list - Developer Guide

How GET /tickets/{id} came to say what is on a ticket and what it is linked to, and how GET /tickets/{id}/attachments lists its files. Built for #328 (5.2.0), asked for by pappas81. User docs: REST API - Tickets (the related block under GET /tickets/{id}, and GET /tickets/{id}/attachments).

The one-line version: one new file, api/v1/resources/ticket_related.php, with one function - apiTicketRelated($conn, $ticketId, $apiKey) - that three places call: the single GET, the list (only with ?include=related), and the webhook engine's Full record loader for tickets. Passing the API key makes it leave out what the key could not read itself; passing null (a webhook) gives everything.


1. Why

Before 5.2.0, GET /tickets/{id} - and the ticket's Full record webhook, which is the same serialiser - said nothing about what hung off the ticket. An integration that wanted the whole picture called /notes, /thread, /forms, /checklists, /watchers... in turn, most of them returning []. And there was no way at all to learn what the ticket was linked to (tasks have a ticket_id; tickets had nothing pointing the other way), or what files were attached.

before                                   after
GET /tickets/42                          GET /tickets/42
GET /tickets/42/notes        -> []         related.counts  -> { notes: 0, messages: 2, attachments: 1, forms: 0, ... }
GET /tickets/42/thread       -> [2]        related.links   -> [ {type: task, id: 41}, {type: ticket, relation: duplicate_of} ]
GET /tickets/42/forms        -> []       GET /tickets/42/thread       (worth calling)
GET /tickets/42/checklists   -> []       GET /tickets/42/attachments  (worth calling)
...

2. The files

File 🎨 What changed
api/v1/resources/ticket_related.php πŸ”Œ new: apiTicketRelated(), apiTicketAttachments(), the handler apiTicketAttachmentsList()
api/v1/resources/tickets.php πŸ”Œ requires it; apiTicketsGet() adds related; apiTicketsList() adds it with ?include=related
api/v1/lib/routes.php πŸ”Œ GET /tickets/{id}/attachments
api/v1/lib/permissions.php πŸ”Œ permission resource ticket_attachments (read)
api/v1/lib/modules.php πŸ”Œ ticket_attachments belongs to Tickets (always on)
api/v1/index.php πŸ”Œ the route in the root endpoint list
workflow/includes/engine.php βš™οΈ the ticket Full record loader appends related (with $apiKey = null)
api/workflow/webhook_test.php βš™οΈ the editor's Send test sample does the same, so the preview matches a real send
api/v1/spec.json πŸ”— the new route; include on GET /tickets; GET /tickets/{id} describes related
api/v1/lib/openapi_schemas.php πŸ”— typed TicketRelated and TicketAttachment; related on Ticket and TicketDetail
includes/feature_bingo/cards/system-integrations.php 🎯 system.api_ticket_related
tests/api-ticket-related.php πŸ§ͺ new: 16 checks over real HTTP

No schema change - every table it reads already existed.


3. The shape

"related": {
  "counts": { "notes": 3, "messages": 5, "attachments": 2, "forms": 1,
              "checklists": 0, "watchers": 2, "time_entries": 0 },
  "links": [
    { "type": "task",    "id": 41,  "name": "Order laptop" },
    { "type": "problem", "id": 7,   "reference": "PRB-0007", "name": "VPN drops" },
    { "type": "ticket",  "id": 990, "reference": "TKT-0990", "relation": "duplicate_of" },
    { "type": "external_issue", "provider": "jira", "reference": "OPS-12",
      "url": "https://example.atlassian.net/browse/OPS-12", "status": "In Progress" }
  ]
}

Counts, not booleans. The issue asked for notes_attached: true/false. A count answers the same question (> 0) and the next one too (is it worth paging?), for the same query.

One count per sub-endpoint, named for what the endpoint returns, so the mapping is obvious:

count endpoint permission how it is counted
notes /notes ticket_notes COUNT(*) FROM ticket_notes
messages /thread ticket_thread COUNT(*) FROM emails
attachments /attachments ticket_attachments count(apiTicketAttachments()) - the list itself, so they can never disagree
forms /forms ticket_forms count(TicketForms::forTicket()) - the endpoint's own call
checklists /checklists ticket_checklists COUNT(*) FROM ticket_checklists
watchers /watchers ticket_watchers count(TicketWatchers::list()) - it leaves out inactive analysts, so a COUNT would not match
time_entries /time-entries ticket_time_entries COUNT(*) ... AND is_active = 1, the endpoint's filter

TRAP: a count must match what its endpoint returns. Where the endpoint filters (watchers drop inactive analysts, forms come from the host logic), the count calls the same service rather than writing a second query that would drift.


4. Who sees what

related must never tell a key more than it could find out by calling the endpoints itself. So:

// api/v1/resources/ticket_related.php
$can = function (string $perm) use ($apiKey): bool {
    if ($apiKey === null) return true;                       // a webhook - see below
    return in_array('read', $apiKey['permissions'][$perm] ?? [], true);
};
...
if ($can('ticket_notes'))  $counts['notes']    = $scalar("SELECT COUNT(*) FROM ticket_notes WHERE ticket_id = ?");
  • A count appears only with read on its endpoint's permission. A key with tickets.read alone gets "counts": {}.
  • A link appears only with read on that record's permission (tasks, problems, changes, projects, assets, cmdb_objects) and if the record is in one of the key's companies:
$inScope = function (string $table, int $id) use ($conn, $apiKey): bool {
    return $apiKey === null || apiKeyCanAccessTenantRow($conn, $apiKey, $table, $id);
};
  • A linked ticket goes through apiKeyCanAccessTicket() - company and the key's analyst's departments, the same gate as GET /tickets/{id}.
  • A linked ticket never carries its subject, only its number and the relation. The other ticket may be confidential, and on the webhook path nothing redacts a ticket that is not the one the event is about. (The ticket the event IS about is still handled by ticketRedactForOutbound() - a confidential ticket gets no .full at all.)
  • A webhook passes null and gets everything: whoever set up the workflow chose to send the record, as with every other field of .full.

TRAP: empty counts must be {}, not []. PHP encodes an empty array as [], which the OpenAPI shape check (rightly) calls a wrongly-serialised object. The function returns $counts ?: new stdClass().


5. Links

Six record kinds come from a table of [type, permission, table, SQL], each SQL giving id, reference and name:

$kinds = [
    ['task',        'tasks',        'tasks',        "SELECT id, NULL AS reference, title AS name FROM tasks WHERE ticket_id = ? ORDER BY id"],
    ['problem',     'problems',     'problems',     "SELECT p.id, p.problem_number AS reference, p.title AS name FROM problem_tickets pt JOIN problems p ON p.id = pt.problem_id WHERE pt.ticket_id = ? ORDER BY p.id"],
    ['change',      'changes',      'changes',      "... FROM change_tickets ct JOIN changes c ..."],
    ['project',     'projects',     'projects',     "... FROM project_tickets pt JOIN projects p ..."],
    ['asset',       'assets',       'assets',       "... FROM ticket_assets ta JOIN assets a ..."],
    ['cmdb_object', 'cmdb_objects', 'cmdb_objects', "... FROM ticket_cmdb_objects tc JOIN cmdb_objects o ..."],
];

Adding a kind is one row. Each query is in its own try, so a module whose tables Database Verify has not created yet simply links nothing.

Ticket to ticket reuses ticketLinksFor() from includes/ticket_links.php - the very grouping the ticket screen draws - and turns its groups into a relation from THIS ticket's side:

$g = ticketLinksFor($conn, $ticketId);   // parent, children[], duplicate_of, duplicates[], related[]
if ($g['parent'])        $links[] = $one($g['parent'], 'child_of');
foreach ($g['children'] as $t)   $links[] = $one($t, 'parent_of');
if ($g['duplicate_of'])  $links[] = $one($g['duplicate_of'], 'duplicate_of');
foreach ($g['duplicates'] as $t) $links[] = $one($t, 'duplicated_by');
foreach ($g['related'] as $t)    $links[] = $one($t, 'related');

Merges add merged_into_this from ticket_merges (the merged ticket is in the trash; GET /tickets/{id}?deleted=true still finds it). External tracker issues come from integration_links joined to integration_connections for the provider - key, address and status in the tracker - under tickets.read, since they are part of the ticket.


6. GET /tickets/{id}/attachments

Attachments were the one thing with no endpoint at all, so a count alone would have been a dead end. apiTicketAttachments() reads two places:

source from left out
message email_attachments joined to the ticket's emails is_inline = 1 - a signature's logo or a picture shown in the body is part of the message, and would bury the real files
note document_links with parent_type = 'ticket_note' on the ticket's notes, joined to documents deleted documents

Tickets have no Documents panel - files on a ticket live on its notes - which is why a note document's source_id is the note.

A document of kind files (a link to a file in the Files module, #303) is listed without its name: in the app the name is shown only to someone who can open the file's folder, and the API does not check Files permissions. It says there is a file here, nothing about it.

It lists; it does not download. Downloading over the API would need its own design (streaming, storage drivers, Files permissions) and was not asked for.


7. The list, and why related is opt-in there

// api/v1/resources/tickets.php - apiTicketsList()
if (in_array('related', array_map('trim', explode(',', (string)($_GET['include'] ?? ''))), true)) {
    foreach ($tickets as &$tk) $tk['related'] = apiTicketRelated($conn, (int)$tk['id'], $apiKey);
    unset($tk);
}

related is about a dozen small queries per ticket. On one ticket that is nothing; on a page of 100 it is 1,200, for callers who mostly do not want it. include is a comma list so another opt-in can join it later.


8. The webhook

The Full record loader for tickets appends it, exactly as the API does but with null:

// workflow/includes/engine.php - fullObjectLoaders()
'ticket' => $one('tickets.php', 'apiTicketSelect', ' WHERE t.id = ? LIMIT 1',
                 fn($c, $r) => apiSerializeTicket($r) + ['related' => apiTicketRelated($c, (int)$r['id'], null)]),

So {{ticket.full.related.counts.attachments}} works in any action, and Send test in the editor (api/workflow/webhook_test.php) builds its sample the same way, so the preview is what a real send carries.


9. Testing

FREEITSM_TEST_DATABASE=<throwaway db> FREEITSM_TEST_URL=http://localhost/<that install> php tests/api-ticket-related.php

Two ZZ tickets (one the parent of the other, the child's subject containing SECRET), a note with a document, a message with a real attachment and an inline picture, a task. Two temporary keys over real HTTP:

  • the full key - counts are 1 note, every message, 2 attachments (the inline picture left out), and forms / checklists / watchers / time entries all present; links hold the task by id and name and the child as parent_of by number; nothing anywhere says SECRET; from the child, the main ticket is child_of.
  • the narrow key (tickets.read only) - counts is {}; no task link; the ticket link stays.
  • /attachments lists the message file and the note document with names, types, sizes and source_id; 403 without ticket_attachments.read.
  • GET /tickets?include=related adds it to every row; without it the list is unchanged.
  • the webhook path - enrichWithFullObjects() gives ticket.full.related with all seven counts and both links.

The OpenAPI document generated with the new schemas passes openapi_check.php and validates against the OpenAPI 3.0 meta-schema; openapi_verify.php against a live install finds nothing wrong with related.


10. Traps

  • A count must match its endpoint - call the endpoint's own service where it filters.
  • $counts ?: new stdClass() - an empty object, not [].
  • Never a linked ticket's subject.
  • $ref takes no siblings in OpenAPI 3.0 - a description beside '$ref' => '#/components/schemas/TicketRelated' is ignored, and some validators reject it. The note went in a PHP comment.
  • Adding a sub-endpoint to tickets? Add its count here too, under its own permission, or related quietly stops being the whole picture.

Related

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Responses to feedback

Blue sky thinking

Bugs resolved

Pull requests

Links

Clone this wiki locally