Repository navigation
Checklists REST API Developer Guide
How an integration reads the checklists on a ticket or a task, and answers their steps, through /api/v1. Built for #273 (5.0.0). User docs: REST API - Tickets β Checklists and REST API - Tasks β Checklists. The checklist engine itself: Checklists and SOPs - Developer Guide and Checklists on tasks - Developer Guide.
The one-line version: four routes in one new file, api/v1/resources/checklists.php. Two GETs return the same Checklist shape for a ticket and a task. Two PATCHes answer one step - tick, untick, tick with a value, or pass / fail / na - by calling the very ChecklistsService methods the ticket and task panels call, so every rule the app has applies unchanged. The only new behaviour inside the service is two lines that mark a change made with ActorContext::source === 'api' as (via the API): on the ticket's step note, and in the task step history.
| File | π¨ | What changed |
|---|---|---|
api/v1/resources/checklists.php |
π |
new: apiChecklistShape(), apiChecklistStepShape(), the four handlers, apiChecklistStepApply()
|
api/v1/lib/routes.php |
π | four routes |
api/v1/lib/permissions.php |
π | permission resources ticket_checklists and task_checklists (read, update) |
api/v1/lib/modules.php |
π | both resources need the Checklists module as well as Tickets / Tasks |
api/v1/index.php |
π | requires the new file (after tickets.php and tasks.php); the four routes in the root endpoint list |
api/v1/spec.json |
π | the four catalogue entries with examples and errors |
api/v1/lib/openapi_schemas.php |
π | typed Checklist and ChecklistStep schemas, bound to the four routes |
includes/services/checklists.php |
βοΈ |
writeStepNote() appends (via the API); recordStepEvent() stores "via": "api" in after_json
|
assets/js/tasks.js, tasks/index.php
|
π₯οΈ | the task History line adds (via the API); cache-buster bumped |
lang/*/tasks.php |
π₯οΈ |
tasks.history.via_api (en, de, es in the commit; id and pl since) |
tests/checklists-api.php |
π§ͺ | new: 21 checks (20 at first, one added by changelog #2800) with real keys through the real front controller |
No schema change. Every table and column the API reads already existed from #181 (task results), #190 (step descriptions) and #271 (the same on tickets).
| Route | Permission | Modules | Handler |
|---|---|---|---|
GET /tickets/{id}/checklists |
ticket_checklists.read |
tickets, checklists | apiTicketChecklistsList() |
PATCH /tickets/{id}/checklists/steps/{step_id} |
ticket_checklists.update |
tickets, checklists | apiTicketChecklistStepUpdate() |
GET /tasks/{id}/checklists |
task_checklists.read |
tasks, checklists | apiTaskChecklistsList() |
PATCH /tasks/{id}/checklists/steps/{step_id} |
task_checklists.update |
tasks, checklists | apiTaskChecklistStepUpdate() |
// api/v1/lib/routes.php
// GH #273: checklists, the same shape on tickets and tasks.
['GET', '#^/tickets/(\d+)/checklists$#', ['ticket_checklists', 'read'], 'apiTicketChecklistsList'],
['PATCH', '#^/tickets/(\d+)/checklists/steps/(\d+)$#', ['ticket_checklists', 'update'], 'apiTicketChecklistStepUpdate'],
// β¦
['GET', '#^/tasks/(\d+)/checklists$#', ['task_checklists', 'read'], 'apiTaskChecklistsList'],
['PATCH', '#^/tasks/(\d+)/checklists/steps/(\d+)$#', ['task_checklists', 'update'], 'apiTaskChecklistStepUpdate'],The step is addressed under its parent (/tickets/{id}/checklists/steps/{step_id}), not as /checklist-steps/{id}. That is what lets the handler gate on the ticket or task first and then refuse a step that is not on it (section 5).
The permission resources are new and separate from tickets / tasks, so a key can be given checklists and nothing else. Their labels are what the key editor in System β API shows:
// api/v1/lib/permissions.php - apiV1PermissionCatalog()
// GH #273. Writes go through the same rules as the ticket panel and are noted on the ticket "via the API".
'ticket_checklists' => [
'label' => 'Ticket checklists',
'actions' => [
'read' => 'Read the checklists on a ticket: each step, what it asks for, its state, result and who completed it',
'update' => 'Tick, untick or record a result (pass / fail / not applicable) on a ticket\'s checklist step',
],
],
// β¦
// GH #273: the same shape as ticket checklists. Empty while checklists on tasks are switched off.
'task_checklists' => [
'label' => 'Task checklists',
'actions' => [
'read' => 'Read the checklists on a task: each step, what it asks for, its state, result and who completed it',
'update' => 'Tick, untick or record a result on a task\'s checklist step (a completed task\'s lock still applies)',
],
],// api/v1/lib/modules.php - API_V1_RESOURCE_MODULES
'ticket_checklists' => ['tickets', 'checklists'], // GH #273: refused while Checklists is switched off
// β¦
'task_checklists' => ['tasks', 'checklists'], // GH #273Several modules in the list means it needs them all. With the Checklists module switched off for the install, all four routes answer 403 module_disabled, whatever the key may do.
Authorization: Bearer fitsm_β¦
ββ apiAuthenticate() 401 / 403 / 429 - the key, its analyst, the rate limit
ββ route match in routes.php 404 unknown endpoint, 405 wrong method
ββ apiRequirePermission() 403 forbidden - ticket_checklists.update missing
ββ apiV1RequireModulesEnabled() 403 module_disabled - Tickets/Tasks or Checklists off
ββ handler
ββ apiLoadTicket / apiLoadTask 404 - no such record, or outside the key's companies
ββ the step belongs to it? 404 - "That step is not on this ticket."
ββ tasks: switched on? 422 disabled
ββ ChecklistsService::β¦ 422 / 404 from ServiceError, via apiFailFromService()
ββ apiRespond(step) 200
The front controller does the first four steps for every route:
// api/v1/index.php
$apiKey = apiAuthenticate($conn);
$allowedForPath = [];
foreach ($routes as [$routeMethod, $pattern, $permission, $handler]) {
if (!preg_match($pattern, $path, $matches)) {
continue;
}
if ($routeMethod !== $method) {
$allowedForPath[] = $routeMethod;
continue;
}
if ($permission !== null) {
apiRequirePermission($apiKey, $permission[0], $permission[1]);
// A module switched off for the install answers 403 module_disabled,
// whatever the key may do (GH #217, lib/modules.php).
apiV1RequireModulesEnabled($conn, $permission[0], $path);
}
array_shift($matches); // drop the full-match element
try {
// Ids arrive as ints. β¦
$handler($conn, $apiKey, array_map(fn($m) => ctype_digit($m) ? (int)$m : $m, $matches), apiJsonBody());
} catch (Exception $e) {
error_log('API v1 handler error [' . $method . ' ' . $path . ']: ' . $e->getMessage());
apiError(500, 'server_error', 'An unexpected server error occurred.');
}
exit; // handlers respond + exit themselves; belt and braces
}So $params[0] is the ticket or task id and $params[1] the step id. apiJsonBody() answers 400 invalid_json for a body that is present but not a JSON object.
Both loaders answer 404, never 403, for a record outside the key's companies, so a key cannot learn that another company's ticket or task id exists:
// api/v1/resources/tickets.php
/** Load one ticket (with joins) enforcing the key's company scope; 404s if not visible. */
function apiLoadTicket(PDO $conn, array $apiKey, int $ticketId): array {
if (!apiKeyCanAccessTicket($conn, $apiKey, $ticketId)) {
apiError(404, 'not_found', 'Ticket not found.');
}
// β¦
}// api/v1/resources/tasks.php
function apiLoadTask(PDO $conn, int $taskId, ?array $apiKey = null): array {
$stmt = $conn->prepare(apiTaskSelect() . " WHERE t.id = ?");
$stmt->execute([$taskId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
apiError(404, 'not_found', 'Task not found.');
}
// β¦
if ($apiKey !== null && !apiKeyCanAccessTenantRow($conn, $apiKey, 'tasks', $taskId)) {
apiError(404, 'not_found', 'Task not found.');
}
return $row;
}Both end in apiKeyCanAccessTenantRow() (api/v1/lib/auth.php), which reads the row's tenant_id (NULL = the Default company) and checks it against $apiKey['company_scope'] (null = all companies). The API gates on company only, like every other /api/v1 route. The in-app task endpoint gates on analystCanAccessTask() instead, which is the analyst's own visibility rule.
// includes/service_context.php
public static function fromApiKey(array $apiKey): self
{
return new self(
actorId: (int)$apiKey['analyst_id'],
companyScope: $apiKey['company_scope'] ?? null, // apiAuthenticate() already computes this (null = all)
source: 'api',
locale: 'en',
actorName: (string)($apiKey['analyst_name'] ?? '')
);
}A key always acts as an analyst. That analyst's id and name go into completed_by_id / completed_by_name, the ticket note's analyst_id, and the task step history's analyst_id / actor_name. source: 'api' is what the service reads to add (via the API) (section 7).
// api/v1/resources/checklists.php
/** GET /tickets/{id}/checklists - every checklist on the ticket, with its steps. */
function apiTicketChecklistsList(PDO $conn, array $apiKey, array $params, array $body): void {
apiLoadTicket($conn, $apiKey, (int)$params[0]); // 404 outside the key's companies
apiRespond(array_map('apiChecklistShape', ChecklistsService::ticketChecklists($conn, (int)$params[0])));
}
/** GET /tasks/{id}/checklists - empty while checklists on tasks are switched off (Tasks -> Settings -> Checklists). */
function apiTaskChecklistsList(PDO $conn, array $apiKey, array $params, array $body): void {
apiLoadTask($conn, (int)$params[0], $apiKey);
if (!ChecklistsService::tasksEnabled($conn)) apiRespond([]);
apiRespond(array_map('apiChecklistShape', ChecklistsService::taskChecklists($conn, (int)$params[0])));
}The task switch is the one the panel honours: "switched OFF means OFF", so the API shows nothing either, even where checklists were attached while it was on.
// includes/services/checklists.php
/** Is Tasks β Settings β Checklists switched on? Off by default; safe before DB Verification. */
public static function tasksEnabled(PDO $conn): bool
{
try {
$st = $conn->prepare("SELECT setting_value FROM system_settings WHERE setting_key = 'tasks_checklists_enabled'");
$st->execute();
return (string)$st->fetchColumn() === '1';
} catch (Throwable) {
return false;
}
}ticketChecklists() is the method the ticket panel uses. It probes for the #271 columns first (ticketCols(), cached per request), so an install that has not yet run Database Verification still reads plain ticks:
// includes/services/checklists.php
public static function ticketChecklists(PDO $conn, int $ticketId): array
{
$tenantId = function_exists('ticketTenantId') ? ticketTenantId($conn, $ticketId) : null;
$cols = self::ticketCols($conn);
$st = $conn->prepare("SELECT id, template_id, title, closure_mode, created_datetime"
. ($cols['version'] ? ', template_version' : '') . "
FROM ticket_checklists WHERE ticket_id = ? ORDER BY id ASC");
$st->execute([$ticketId]);
$lists = $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
$extra = ($cols['result'] ? ', result_mode, result, exception_note' : '') . ($cols['description'] ? ', description' : '');
$items = $conn->prepare("SELECT id, title, suggested_role, is_mandatory, requires_input, input_placeholder,
response_value, is_completed, completed_by_id, completed_by_name, completed_datetime{$extra}
FROM ticket_checklist_items WHERE ticket_checklist_id = ?
ORDER BY sort_order ASC, id ASC");
foreach ($lists as &$l) {
$l['closure_mode'] = $l['closure_mode'] ?: 'warn';
$l['effective_closure_mode'] = self::effectiveClosureMode($conn, $l['closure_mode'], $tenantId);
$items->execute([(int)$l['id']]);
$l['items'] = $items->fetchAll(PDO::FETCH_ASSOC) ?: [];
$done = count(array_filter($l['items'], fn($i) => !empty($i['is_completed'])));
$l['total_items'] = count($l['items']);
$l['completed_items'] = $done;
$l['percent'] = $l['total_items'] > 0 ? (int)round($done / $l['total_items'] * 100) : 0;
}
unset($l);
return $lists;
}The same idea; task checklists have their own sort_order, so the user can reorder them:
// includes/services/checklists.php
public static function taskChecklists(PDO $conn, int $taskId): array
{
$tenantId = self::taskTenantId($conn, $taskId);
// template_version: GET /tasks/{id}/checklists returned it as null on every task because it
// was never selected - the ticket twin does (#273's "one shape"). Tolerant of an install that
// has not run Database Verify since the column arrived.
try {
$st = $conn->prepare("SELECT id, template_id, title, closure_mode, sort_order, created_datetime, template_version
FROM task_checklists WHERE task_id = ? ORDER BY sort_order ASC, id ASC");
$st->execute([$taskId]);
} catch (PDOException $e) {
$st = $conn->prepare("SELECT id, template_id, title, closure_mode, sort_order, created_datetime
FROM task_checklists WHERE task_id = ? ORDER BY sort_order ASC, id ASC");
$st->execute([$taskId]);
}
$lists = $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
try {
$conn->query("SELECT result_mode FROM task_checklist_items LIMIT 0");
$resultCols = ', result_mode, result, exception_note';
} catch (Throwable) {
$resultCols = ''; // before Database Verification has added #181's columns
}
if (self::hasStepDescription($conn)) $resultCols .= ', description'; // GH #190
$items = $conn->prepare("SELECT id, title, suggested_role, is_mandatory, requires_input, input_placeholder,
response_value, is_completed, completed_by_id, completed_by_name, completed_datetime{$resultCols}
FROM task_checklist_items WHERE task_checklist_id = ?
ORDER BY sort_order ASC, id ASC");
foreach ($lists as &$l) {
// β¦ the same loop as ticketChecklists()
}
unset($l);
return $lists;
}template_version is selected as on tickets, so GET /tasks/{id}/checklists reports the template version the checklist was copied from (changelog #2800). Before that fix it was left out of the SELECT and the API answered "template_version": null for every task checklist. If the column is missing (an install that has not run Database Verify since it arrived) the PDOException falls back to the old SELECT, and the key comes back null - see Traps.
effective_closure_mode is the gate the server will actually apply: the checklist's own mode, unless the company setting is block all. It is resolved here so the API reports the lock the close will enforce, not the template's wish:
public static function effectiveClosureMode(PDO $conn, string $mode, ?int $tenantId = null): string
{
if (function_exists('ticketChecklistClosureMode')
&& ticketChecklistClosureMode($conn, $tenantId) === 'block_all') {
return 'block';
}
return $mode === 'block' ? 'block' : 'warn';
}The service rows are internal (column names, percent, completed_by_id). Two small functions turn them into the public shape. Both GETs and both PATCHes use them, which is the whole of "one shape":
// api/v1/resources/checklists.php
function apiChecklistShape(array $c): array {
return [
'id' => (int)$c['id'],
'title' => (string)$c['title'],
'template_id' => $c['template_id'] !== null ? (int)$c['template_id'] : null,
'template_version' => isset($c['template_version']) && $c['template_version'] !== null ? (int)$c['template_version'] : null,
// What closing / completing does with outstanding mandatory steps, after the
// company setting: 'warn' (allowed and recorded) or 'block' (refused).
'closure' => ($c['effective_closure_mode'] ?? 'warn') === 'block' ? 'block' : 'warn',
'steps_done' => (int)($c['completed_items'] ?? 0),
'steps_total' => (int)($c['total_items'] ?? 0),
'steps' => array_map('apiChecklistStepShape', $c['items'] ?? []),
];
}
function apiChecklistStepShape(array $i): array {
$asks = ($i['result_mode'] ?? 'none') === 'pass_fail' ? 'result' : ((int)($i['requires_input'] ?? 0) === 1 ? 'value' : 'tick');
return [
'id' => (int)$i['id'],
'title' => (string)$i['title'],
'description' => ($i['description'] ?? '') !== '' ? (string)$i['description'] : null,
'mandatory' => (int)($i['is_mandatory'] ?? 0) === 1,
'suggested_role' => ($i['suggested_role'] ?? '') !== '' ? (string)$i['suggested_role'] : null,
'asks_for' => $asks,
'value_prompt' => $asks === 'value' && ($i['input_placeholder'] ?? '') !== '' ? (string)$i['input_placeholder'] : null,
'completed' => (int)($i['is_completed'] ?? 0) === 1,
'value' => ($i['response_value'] ?? '') !== '' ? (string)$i['response_value'] : null,
'result' => in_array($i['result'] ?? null, ['pass', 'fail', 'na'], true) ? $i['result'] : null,
'note' => ($i['exception_note'] ?? '') !== '' ? (string)$i['exception_note'] : null,
'completed_by' => ($i['completed_by_name'] ?? '') !== '' ? (string)$i['completed_by_name'] : null,
'completed_at' => !empty($i['completed_datetime']) ? apiIsoDate($i['completed_datetime']) : null,
];
}| API field | From | Note |
|---|---|---|
closure |
effective_closure_mode |
after the company setting, not the template's raw closure_mode
|
steps_done / steps_total
|
completed_items / total_items
|
percent is left out - the caller can divide |
asks_for |
result_mode, then requires_input
|
pass_fail wins: a result step is never a value step |
value_prompt |
input_placeholder |
only on a value step |
note |
exception_note |
required on a fail, optional on pass / na |
completed_by |
completed_by_name |
the name as it was, so a renamed or departed analyst still reads right; the id is not exposed |
completed_at |
completed_datetime |
stored UTC (#126), sent as ISO 8601 with Z by apiIsoDate()
|
description, result, note
|
optional columns | null before Database Verification adds them; asks_for is then tick or value
|
Empty strings become null throughout, so a caller tests one thing, not two.
{ "data": [ {
"id": 31, "title": "New starter", "template_id": 4, "template_version": 3,
"closure": "block", "steps_done": 1, "steps_total": 3,
"steps": [
{ "id": 318, "title": "Asset tag", "description": null, "mandatory": true, "suggested_role": "Desktop",
"asks_for": "value", "value_prompt": "The laptop's tag", "completed": true, "value": "AT-4471",
"result": null, "note": null, "completed_by": "Sam Patel", "completed_at": "2026-11-11T10:02:00Z" },
{ "id": 319, "title": "Backups verified", "description": "Check last night's job.", "mandatory": true,
"suggested_role": null, "asks_for": "result", "value_prompt": null, "completed": false, "value": null,
"result": null, "note": null, "completed_by": null, "completed_at": null }
] } ] }Both PATCH handlers gate on their parent and hand over to one shared function:
// api/v1/resources/checklists.php
function apiTicketChecklistStepUpdate(PDO $conn, array $apiKey, array $params, array $body): void {
apiLoadTicket($conn, $apiKey, (int)$params[0]);
apiChecklistStepApply($conn, $apiKey, 'ticket', (int)$params[0], (int)$params[1], $body);
}
/** PATCH /tasks/{id}/checklists/steps/{step_id} - the same body as on a ticket. */
function apiTaskChecklistStepUpdate(PDO $conn, array $apiKey, array $params, array $body): void {
apiLoadTask($conn, (int)$params[0], $apiKey);
apiChecklistStepApply($conn, $apiKey, 'task', (int)$params[0], (int)$params[1], $body);
}| Body | On a step that asks for | Calls |
|---|---|---|
{ "completed": true } |
tick |
toggleβ¦Item(β¦, true, null) |
{ "completed": true, "value": "AT-4471" } |
value |
toggleβ¦Item(β¦, true, 'AT-4471') |
{ "completed": false } |
any |
toggleβ¦Item(β¦, false, null) - clears the value and any result |
{ "result": "pass" } / { "result": "na" }
|
result |
setβ¦ItemResult(β¦, 'pass', null) |
{ "result": "fail", "note": "β¦" } |
result |
setβ¦ItemResult(β¦, 'fail', 'β¦') |
completed and result are two different questions, so the handler wants exactly one of them. It does not guess which the caller meant.
// api/v1/resources/checklists.php
function apiChecklistStepApply(PDO $conn, array $apiKey, string $kind, int $ownerId, int $stepId, array $body): void {
try {
$owner = $kind === 'ticket' ? ChecklistsService::ticketIdForItem($conn, $stepId) : ChecklistsService::taskIdForItem($conn, $stepId);
if ($owner !== $ownerId) {
throw new ServiceError('not_found', 'not_found', 'That step is not on this ' . $kind . '.');
}
if ($kind === 'task' && !ChecklistsService::tasksEnabled($conn)) {
throw new ServiceError('validation', 'disabled', 'Checklists on tasks are switched off (Tasks -> Settings -> Checklists).');
}
$hasResult = array_key_exists('result', $body);
$hasTick = array_key_exists('completed', $body);
if ($hasResult === $hasTick) {
throw new ServiceError('validation', 'invalid_field', 'Send either "completed" (to tick or untick) or "result" (pass, fail or na) - one of them.');
}
$ctx = ActorContext::fromApiKey($apiKey);
if ($hasResult) {
$note = isset($body['note']) ? (string)$body['note'] : null;
$kind === 'ticket'
? ChecklistsService::setTicketItemResult($conn, $ctx, $stepId, (string)$body['result'], $note)
: ChecklistsService::setTaskItemResult($conn, $ctx, $stepId, (string)$body['result'], $note);
} else {
if (!is_bool($body['completed'])) {
throw new ServiceError('validation', 'invalid_field', '"completed" must be true or false.');
}
$value = isset($body['value']) ? (string)$body['value'] : null;
$kind === 'ticket'
? ChecklistsService::toggleTicketItem($conn, $ctx, $stepId, $body['completed'], $value)
: ChecklistsService::toggleTaskItem($conn, $ctx, $stepId, $body['completed'], $value);
}
$lists = $kind === 'ticket' ? ChecklistsService::ticketChecklists($conn, $ownerId) : ChecklistsService::taskChecklists($conn, $ownerId);
foreach ($lists as $c) {
foreach ($c['items'] as $i) {
if ((int)$i['id'] === $stepId) apiRespond(apiChecklistStepShape($i));
}
}
throw new ServiceError('not_found', 'not_found', 'Step not found.');
} catch (ServiceError $e) { apiFailFromService($e); }
}Four points:
-
The step must belong to the parent in the path. The parent was gated by
apiLoadTicket/apiLoadTask. The step is then refused unless it is on that parent, so a step in another company can never be reached by its id alone. A ticket step addressed as a task step is a 404 too:taskIdForItem()looks intask_checklist_items, finds nothing and returns 0. -
completedmust be a real JSON boolean."yes",1or"true"is a 422. The in-app endpoints use!empty(...), which is fine for a form post but too loose for a machine. -
The service's return value is ignored.
toggleTicketItem()returnsticketItemRow(), which has nois_mandatory,suggested_roleordescription. The handler re-reads the parent's checklists and answers the step from that, so a PATCH returns exactly the step a GET would. - Validation lives in the service, not here. The handler checks only the API's own contract (one of two keys, a boolean). Whether a value is needed, whether the step asks for a result, whether a fail has a note, whether a completed task is locked - all of that is the service's, so the API cannot drift from the panels.
The lookups behind point 1:
// includes/services/checklists.php
public static function ticketIdForItem(PDO $conn, int $itemId): int
{
$st = $conn->prepare("SELECT c.ticket_id FROM ticket_checklist_items i
JOIN ticket_checklists c ON c.id = i.ticket_checklist_id WHERE i.id = ?");
$st->execute([$itemId]);
return (int)($st->fetchColumn() ?: 0);
}
public static function taskIdForItem(PDO $conn, int $itemId): int
{
$st = $conn->prepare("SELECT c.task_id FROM task_checklist_items i
JOIN task_checklists c ON c.id = i.task_checklist_id WHERE i.id = ?");
$st->execute([$itemId]);
return (int)($st->fetchColumn() ?: 0);
}These are the methods api/tickets/ticket_checklists.php (toggle_item, set_result) and api/tasks/checklists.php (toggle, result) call from the panels. Nothing in them was written for the API, apart from the two via the API lines in section 7.
// includes/services/checklists.php
public static function toggleTicketItem(PDO $conn, ActorContext $ctx, int $itemId, bool $completed, ?string $value): array
{
$row = self::ticketItemRow($conn, $itemId);
if (!$row) {
throw new ServiceError('not_found', 'not_found', 'Step not found.');
}
if ($completed && ($row['result_mode'] ?? 'none') === 'pass_fail' && empty($row['result'])) {
throw new ServiceError('validation', 'missing_field', 'Record a result for this step - Pass, Fail or Not applicable.');
}
$value = ((int)$row['requires_input'] === 1 && $value !== null) ? trim($value) : null;
if ($completed && (int)$row['requires_input'] === 1 && ($value === null || $value === '')) {
throw new ServiceError('validation', 'missing_field', 'This step needs a value before it can be ticked.');
}
if ($completed) {
$conn->prepare("UPDATE ticket_checklist_items
SET is_completed = 1, response_value = ?, completed_by_id = ?,
completed_by_name = ?, completed_datetime = UTC_TIMESTAMP()
WHERE id = ?")
->execute([$value !== '' ? $value : null, $ctx->actorId > 0 ? $ctx->actorId : null,
$ctx->actorName !== '' ? $ctx->actorName : null, $itemId]);
} else {
$conn->prepare("UPDATE ticket_checklist_items
SET is_completed = 0, response_value = NULL, completed_by_id = NULL,
completed_by_name = NULL, completed_datetime = NULL
WHERE id = ?")
->execute([$itemId]);
if (array_key_exists('result_mode', $row)) {
$conn->prepare("UPDATE ticket_checklist_items SET result = NULL, exception_note = NULL WHERE id = ?")->execute([$itemId]);
}
}
$label = '[' . $row['checklist_title'] . '] "' . $row['title'] . '"';
self::writeStepNote($conn, $ctx, (int)$row['ticket_id'], $completed
? 'β
Checklist step completed: ' . $label . ($value !== null && $value !== '' ? ' β Response: ' . $value : '')
: 'β©οΈ Checklist step reopened: ' . $label . ' was marked incomplete');
return self::ticketItemRow($conn, $itemId) ?: [];
}The rules a caller meets:
- A
resultstep cannot be ticked withcompleted: trueuntil it has a result -422 missing_field. Answer it withresultinstead. - A
valuesent to atickstep is dropped, not stored: a stray value on a plain tick would show up as an answer to a question nobody asked (the task twin's comment). - A
valuestep needs a non-blank value -422 missing_field. The value is trimmed. - Unticking clears the value, who, when, and any result and note.
The row it reads:
private static function ticketItemRow(PDO $conn, int $itemId): ?array
{
$extra = self::ticketCols($conn)['result'] ? ', i.result_mode, i.result, i.exception_note' : '';
$st = $conn->prepare("SELECT i.id, i.title, i.requires_input, i.response_value, i.is_completed, i.completed_by_id,
i.completed_by_name, i.completed_datetime{$extra},
c.id AS checklist_id, c.title AS checklist_title, c.ticket_id
FROM ticket_checklist_items i JOIN ticket_checklists c ON c.id = i.ticket_checklist_id
WHERE i.id = ?");
$st->execute([$itemId]);
return $st->fetch(PDO::FETCH_ASSOC) ?: null;
} public static function setTicketItemResult(PDO $conn, ActorContext $ctx, int $itemId, string $result, ?string $note): array
{
$row = self::ticketItemRow($conn, $itemId);
if (!$row) {
throw new ServiceError('not_found', 'not_found', 'Step not found.');
}
if (($row['result_mode'] ?? 'none') !== 'pass_fail') {
throw new ServiceError('validation', 'invalid_field', 'This step does not ask for a result.');
}
if (!in_array($result, ['pass', 'fail', 'na'], true)) {
throw new ServiceError('validation', 'invalid_field', 'A result is pass, fail or na.');
}
$note = $note !== null ? trim($note) : null;
if ($result === 'fail' && ($note === null || $note === '')) {
throw new ServiceError('validation', 'missing_field', 'Say what failed - a failed step needs an exception note.');
}
$conn->prepare("UPDATE ticket_checklist_items
SET result = ?, exception_note = ?, is_completed = 1,
completed_by_id = ?, completed_by_name = ?, completed_datetime = UTC_TIMESTAMP()
WHERE id = ?")
->execute([$result, $note !== '' ? $note : null, $ctx->actorId > 0 ? $ctx->actorId : null,
$ctx->actorName !== '' ? $ctx->actorName : null, $itemId]);
$label = '[' . $row['checklist_title'] . '] "' . $row['title'] . '"';
self::writeStepNote($conn, $ctx, (int)$row['ticket_id'], match ($result) {
'pass' => 'β
Checklist step passed: ' . $label,
'fail' => 'β Checklist step failed: ' . $label . ' β Exception: ' . $note,
default => 'β Checklist step not applicable: ' . $label,
} . ($result !== 'fail' && $note !== null && $note !== '' ? ' β Note: ' . $note : ''));
return self::ticketItemRow($conn, $itemId) ?: [];
}A result ticks the step (is_completed = 1). So a failed step is answered, not outstanding: it never holds up the close. It is on the record instead, which is the point of #181.
toggleTaskItem() and setTaskItemResult() run the same checks and the same UPDATEs on task_checklist_items. Two differences:
The completed-task lock. Tasks β Settings β Evidence can refuse changes to a completed task's checklist. Both task methods call it, so the API meets it as 422 checklist_locked. Tickets have no such setting.
public static function assertTaskChecklistChangeAllowed(PDO $conn, int $taskId): void
{
if (!self::taskIsClosed($conn, $taskId)) return;
try {
$s = $conn->prepare("SELECT setting_value FROM system_settings WHERE setting_key = 'tasks_evidence_completed_checklist'");
$s->execute();
if ((string)$s->fetchColumn() !== 'refuse') return;
} catch (Throwable) {
return;
}
throw new ServiceError('validation', 'checklist_locked',
function_exists('t') ? t('tasks.evidence.checklist_locked') : 'This task is complete, so its checklist is locked. Reopen the task to change it.');
}toggleTaskItem() checks the lock before its other rules; setTaskItemResult() checks it after validating the result and note. So an invalid result on a locked task answers invalid_field / missing_field, not checklist_locked.
The history. A ticket step's change is written as a note on the ticket. A task step's change is a row in task_checklist_item_events, with the before and after state as JSON (section 7):
// toggleTaskItem(), after the UPDATE
$after = self::taskItemRow($conn, $itemId) ?: [];
$action = $completed ? (!empty($row['is_completed']) ? 'value' : 'ticked') : 'unticked';
self::recordStepEvent($conn, $ctx, $row, $after, $action);setTaskItemResult() records the action result.
A change made through a key is recorded as the key's analyst. Read on its own, that note says the analyst ticked the step by hand. So the service marks it, keyed on $ctx->source, which only ActorContext::fromApiKey() sets to 'api' on this path.
// includes/services/checklists.php
private static function writeStepNote(PDO $conn, ActorContext $ctx, int $ticketId, string $text): void
{
// GH #273: a step changed through the REST API says so, as a person reading
// the ticket would otherwise take the key's analyst to have done it by hand.
if ($ctx->source === 'api') $text .= ' (via the API)';
try {
if ($ctx->actorId > 0) {
require_once __DIR__ . '/tickets.php';
TicketsService::createNote($conn, $ctx, $ticketId, ['text' => $text, 'is_internal' => true]);
return;
}
$conn->prepare("INSERT INTO ticket_notes (ticket_id, analyst_id, note_text, is_internal, created_datetime)
VALUES (?, NULL, ?, 1, UTC_TIMESTAMP())")->execute([$ticketId, $text]);
} catch (Throwable $e) {
error_log('[checklists] could not record a step change on ticket ' . $ticketId . ': ' . $e->getMessage());
}
}A key always has an analyst, so the API path is TicketsService::createNote(): an internal note by that analyst, and ticket.note_added fires for it exactly as for a tick in the panel. The result reads:
β
Checklist step completed: [New starter] "Asset tag" β Response: AT-4471 (via the API)
The note never throws. The step is saved whether or not its note is.
private static function recordStepEvent(PDO $conn, ActorContext $ctx, array $before, array $after, string $action): void
{
$pick = fn(array $r) => array_intersect_key($r, array_flip(['is_completed', 'response_value', 'completed_by_name',
'completed_datetime', 'result', 'exception_note']));
$afterJson = $pick($after);
if ($ctx->source === 'api') $afterJson['via'] = 'api'; // GH #273: the history line says "via the API"
try {
$conn->prepare("INSERT INTO task_checklist_item_events (task_id, task_checklist_id, item_id, checklist_title, item_title,
action, before_json, after_json, after_completion, analyst_id, actor_name, created_datetime)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP())")
->execute([(int)$before['task_id'], (int)$before['checklist_id'], (int)$before['id'], $before['checklist_title'],
$before['title'], $action, json_encode($pick($before)), json_encode($afterJson),
self::taskIsClosed($conn, (int)$before['task_id']) ? 1 : 0,
$ctx->actorId > 0 ? $ctx->actorId : null, $ctx->actorName !== '' ? $ctx->actorName : null]);
} catch (Throwable $e) {
error_log('[checklists] could not record a step change on item ' . ($before['id'] ?? '?') . ': ' . $e->getMessage());
}
}The mark goes into after_json rather than a new column, so there is no schema change and no Database Verification step. The task's History tab reads it:
// assets/js/tasks.js - loadTaskHistory()
if (after.exception_note) what += ' - ' + after.exception_note;
if (det.after_completion) what += ' ' + T('after_completion');
if (after.via === 'api') what += ' ' + T('via_api'); // GH #273tasks.history.via_api is in lang/{en,de,es,id,pl}/tasks.php. The ticket note is stored text, so it is English whatever the reader's language, like every other step note.
Every service error goes through one mapper:
// api/v1/lib/response.php
function apiFailFromService(ServiceError $e): void {
apiError(serviceErrorHttpStatus($e->kind), $e->errorCode, $e->getMessage());
}// includes/service_context.php
function serviceErrorHttpStatus(string $kind): int
{
switch ($kind) {
case 'bad_request': return 400; // malformed input (e.g. an unparseable date/time)
case 'not_found': return 404;
case 'forbidden': return 403;
case 'conflict': return 409;
case 'validation':
default: return 422;
}
}The body is always { "error": { "code": "β¦", "message": "β¦" } }.
| Status | code |
When | Raised by |
|---|---|---|---|
| 400 | invalid_json |
the body is present but not a JSON object | apiJsonBody() |
| 401 | unauthenticated |
no key, or an unknown key | apiAuthenticate() |
| 403 |
key_disabled / key_expired
|
the key, or its analyst, is off | apiAuthenticate() |
| 403 | forbidden |
the key lacks ticket_checklists.* / task_checklists.*
|
apiRequirePermission() |
| 403 | module_disabled |
Tickets / Tasks or Checklists is switched off | apiV1RequireModulesEnabled() |
| 404 | not_found |
no such ticket / task, or outside the key's companies |
apiLoadTicket() / apiLoadTask()
|
| 404 | not_found |
the step is not on this ticket / task (or does not exist) | apiChecklistStepApply() |
| 422 | disabled |
a task PATCH while checklists on tasks are off | apiChecklistStepApply() |
| 422 | invalid_field |
neither or both of completed / result; completed not a boolean |
apiChecklistStepApply() |
| 422 | invalid_field |
a result on a step that does not ask for one; a result other than pass / fail / na | setβ¦ItemResult() |
| 422 | missing_field |
a value step ticked without a value; a result step ticked; a fail with no note |
toggleβ¦Item() / setβ¦ItemResult()
|
| 422 | checklist_locked |
a completed task, with Evidence set to refuse | assertTaskChecklistChangeAllowed() |
| 429 | rate_limited |
over the key's per-minute limit | apiRateLimit() |
A task GET while checklists on tasks are off is not an error: it is 200 with "data": [].
Since #271, a ticket step and a task step are the same thing: the same columns (result_mode, result, exception_note, description, template_version), the same rules, the same ChecklistsService. ticketChecklists() is written to return "the same shape taskChecklists() returns".
So the API has one Checklist and one ChecklistStep schema, one body for both PATCHes, and one function (apiChecklistStepApply()) for both writes. An integration that runs a procedure - a new starter, a change's pre-checks - does not care whether the procedure sits on a ticket or a task. It reads asks_for and answers accordingly.
The differences that remain are real differences in the product, and the API passes them through rather than hiding them:
| Ticket | Task | |
|---|---|---|
| Order of checklists | id |
sort_order, then id (reorderable) |
| Switched off | only with the Checklists module | also Tasks β Settings β Checklists: GET empty, PATCH 422 disabled
|
| After closing | no lock | Evidence setting can lock it: 422 checklist_locked
|
| Where a change is recorded | an internal note on the ticket | a row in task_checklist_item_events, shown in History |
closure means |
closing the ticket | completing the task |
api/v1/spec.json is the one catalogue both the docs page (system/api/docs.php) and the OpenAPI generator read. Each route has an entry with m, p, perm, s (summary), d (description), params (path and body), an example body, and extras (examples and errors). The ticket routes sit in the section Ticket notes, conversation & history; the task routes in Tasks.
{
"m": "PATCH",
"p": "\/tickets\/{id}\/checklists\/steps\/{step_id}",
"perm": "ticket_checklists.update",
"s": "Answer a ticket checklist step",
"params": [
{ "name": "id", "in": "path", "desc": "Ticket id", "req": true },
{ "name": "step_id", "in": "path", "desc": "The step's id, from GET \/tickets\/{id}\/checklists", "req": true },
{ "name": "completed", "in": "body", "desc": "true to tick, false to untick (send this or result)", "req": false },
β¦
],
"body": { "completed": true, "value": "AT-4471" },
β¦
}The typed response schemas live in api/v1/lib/openapi_schemas.php: components Checklist and ChecklistStep, and the four route bindings (the GETs are type: array of Checklist, the PATCHes a $ref to ChecklistStep). Nullable fields carry a type beside nullable: true, and closure, asks_for and result are enums.
The rules, from api/v1/dev/README.md and Maintaining the API catalogue:
- Routes in
routes.php, permissions inpermissions.php- and, for a switchable module,modules.php(section 11, trap 1). - Catalogue entries in
spec.json(php api/v1/dev/openapi_stub.phpscaffolds them). - Typed schemas brought in line with live responses (
openapi_fix.php <read_key>, thenopenapi_verify.php <read_key>). - The invariants:
php api/v1/lib/openapi_check.php. It fails on:-
drift - a route with no
spec.jsonentry or the reverse (it probes each path with{β¦}replaced by1, so/tickets/1/checklists/steps/1must match the route regex); - a dangling
$ref(ChecklistStepmust be defined); - duplicate or missing operationIds; an operation with no responses;
- an empty array where an object belongs;
-
nullablewithout atype; - a malformed catalogue entry (method, path, summary,
perm, each param'snameandinof path / query / body).
-
drift - a route with no
- Meta-schema conformance:
php api/v1/dev/jsonschema4.phpagainstoas-3.0-schema.json.
spec.json mixes escaped (\/) and unescaped Unicode, so the four entries were added as text. A decode and re-encode of the whole file is never byte-identical.
The root endpoint (GET /api/v1/) lists the routes by hand in apiHandleRoot() (api/v1/index.php), and the four are there too. Nothing checks that list, so it has to be kept by eye.
// api/v1/lib/modules.php
* TRAP: a NEW resource for a switchable module must be added to the map below, or
* it keeps answering while its module is off. tests/module-availability.php fails
* for any permission resource that is in neither map.ticket_checklists is listed with ['tickets', 'checklists'], not ['tickets'] alone. Without checklists, the routes would keep answering on an install that has switched the Checklists module off.
apiLoadTicket($conn, $apiKey, (int)$params[0]);
apiLoadTask($conn, (int)$params[0], $apiKey);$apiKey is the optional third argument of apiLoadTask() (two post-write re-loads skip it). Leave it off and the task is loaded with no company check. Every by-id read in an API handler must pass it.
A step id is global. Checking that the key may see a ticket proves nothing about the step. apiChecklistStepApply() compares ticketIdForItem() / taskIdForItem() with the id in the path before anything else. A new child route must do the same - see trap 3 in Checklists on tasks - Developer Guide.
// includes/service_context.php
public string $source = 'api', // 'ui' | 'api'(via the API) is keyed on $ctx->source === 'api'. A caller that builds new ActorContext($id) with fewer than three arguments gets 'api' by default, and any checklist step it changes will be noted (via the API). The panels use ActorContext::fromSession() ('ui') and workflows pass 'workflow', so today only fromApiKey() reaches these methods with 'api'. Pass the source explicitly when adding a caller.
toggleTicketItem() / setTicketItemResult() return ticketItemRow(), which lacks is_mandatory, suggested_role, input_placeholder and description. Fed to apiChecklistStepShape(), that would answer a PATCH with mandatory: false and suggested_role, value_prompt and description all null - wrong, with no error to show it. That is why the handler re-reads the parent's checklists.
taskChecklists() once left template_version out of its SELECT, so apiChecklistShape() found no key and GET /tasks/{id}/checklists returned "template_version": null for every checklist, while the ticket twin returned the real number. The test compared the two shapes by keys only, and apiChecklistShape() always emits the key, so it passed. Fixed in changelog #2800: the column is selected (with a try/catch fallback for a schema without it), and the test now sets the template's version_no to 3 before attaching and checks the task's value equals task_checklists.template_version. When adding a column to either shape, add it to both SELECTs and check a value, not just a key.
(string)$body['value'] on a JSON array raises a PHP warning and gives "Array". On a value step that is stored as the answer. The handler checks completed with is_bool(), but not the other three fields with is_string().
task_checklists maps to ['tasks', 'checklists'], so with the Checklists module off both task routes answer 403 module_disabled. The in-app endpoint api/tasks/checklists.php only calls requireModuleAccessJson('tasks') and honours Tasks β Settings β Checklists, so the panel keeps working. The ticket side agrees with itself: api/tickets/ticket_checklists.php calls requireModuleOnJson(β¦, 'checklists'). The changelog row (#2782) says the mapping is meant, so this is a known difference, not an accident - but it is one place where the API is stricter than the app.
tests/checklists-api.php runs on a disposable install, through the real front controller (/api/v1/index.php?path=β¦) with real keys. It writes to the database, so it refuses to run over the web, and it skips unless both variables are set and config.php points at the named database:
FREEITSM_TEST_DATABASE=<throwaway db> FREEITSM_TEST_URL=http://localhost/<that install> php tests/checklists-api.php
It runs Database Verify (scripts/db_verify_cli.php --apply) first. Then it makes a ZZ analyst (admin, all modules, all companies), three keys - full (read + update on both), read (read on both) and none (tickets.read only) - and a template with three steps: a plain tick, a value step (Asset tag, prompt The tag) and a result step (Backups verified, with a description). It attaches the template to the first ticket and to a new ZZ task, with checklists on tasks switched on. Everything is removed in finally, and tasks_checklists_enabled is put back as it was.
21 checks:
| Area | Proves |
|---|---|
| Reading | the ticket GET is 200; asks_for is tick, value, result for the three steps; value_prompt, description and closure: block come through; the task GET is 200 with the same keys at checklist and step level; a key without ticket_checklists gets 403 |
| Answering | a read-only key cannot PATCH (403); a step addressed through another ticket is 404; a value step without a value is 422 missing_field, and 200 with one, returning completed, value and completed_by; completed and result together is 422 invalid_field; completed: "yes" is 422; a fail without a note is 422 missing_field, and with one is 200, completed, with its note |
| Recorded | the ticket note is the key's analyst's and ends in (via the API)
|
| Tasks | the task's template_version is its real value (the template is saved as version 3 before attaching), not null; a task step answered na; its task_checklist_item_events.after_json has "via": "api"; a ticket's step addressed as a task step is 404; with checklists on tasks off, the GET is [] and the PATCH is 422 disabled
|
Not covered here: checklist_locked on a completed task, module_disabled, and a key scoped to another company.
Also run after a change here: php api/v1/lib/openapi_check.php, tests/module-availability.php and tests/ticket-checklists.php. See Developer tests - Tickets.
- REST API - Tickets β Checklists Β· REST API - Tasks β Checklists Β· REST API
- Checklists and SOPs - Developer Guide Β· Checklists on tasks - Developer Guide Β· Checklist closure gating - Developer Guide
- Maintaining the API catalogue Β· REST API - OpenAPI Β· Service layer architecture
- Forms on tickets - Developer Guide (2 of 2), the same pattern for forms Β· Code traps
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
-
Architecture
- β³ π§Ή Root folder tidy (URL routing)
- β³ π Database Integrity (FKs & constraints)
- β³ π οΈ Database Verification β Developer Guide
- β³ π§© Service Layer (UI + API share code)
- β³ πͺ€ Code traps (the TRAP: comments)
- β³ π Commenting for the Engineering wiki
- β³ π οΈ Engineering wiki scanner - Developer Guide
- β³ π Service-Layer progress
- π€ Contributing (read before a pull request)
- π§ͺ Developer tests
- π PDF exports moved to PDFKit (#254)
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- π± Currencies
- π§ The sidebar (optional module rail)
- π§© Preference templates
- π Display order (modules, Watchtower cards) β Developer Guide
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: Projects
- β³ ποΈ Mobile: Files
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- β³ π Switching modules off
- β³ π οΈ Module availability β Developer Guide
- β³ π Module availability β design (#217)
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
-
π¦ Microsoft Entra ID
- β³ π₯ Importing people and groups
- β³ π» Intune: assets to their primary user
- β³ π’ Organisation chart
- β³ π οΈ Entra directory sync β Developer Guide
- β³ π οΈ Entra import - review (#235)
- β³ π οΈ Entra follow-ups - review (#237-#240)
- β³ π οΈ Intune primary user - review (#236)
- β³ π οΈ Organisation chart - reviews (#242, #265)
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π Proposal: custom fields, inventory import and change evidence (#194)
- β³ π οΈ Custom fields, evidence + inventory - review (#195-#197)
- β³ π οΈ Long forms in a task's Work view - review (#198)
- β³ π οΈ Empty Procedures fold - review (#247)
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π REST API: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ π§βπΌ REST API: Analysts
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Live updates
- β³ π οΈ Watching a ticket - Developer Guide (1 of 2)
- β³ π οΈ CSAT delay and reminder - Developer Guide
- β³ π οΈ Ticket checklist step types - Developer Guide (1 of 2)
- β³ π οΈ Right-click Attach checklist - Developer Guide
- β³ π οΈ Checklists in the REST API - Developer Guide
- β³ π οΈ Inbox.js map β Developer Guide
- β³ π οΈ Floating windows & the file viewer β Developer Guide
- β³ π οΈ Tickets settings β Developer Guide
- β³ π Simpler ticket and task forms - design (#218)
- β³ ποΈ The ticket workspace
- β³ π Forms on tickets
- β³ π The ticket table
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π§Ύ Custom ticket fields
- β³ π·οΈ Ticket categories
- β³ π§Ύ A type for every new ticket
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ π Ticket dashboard β Developer Guide
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ π Attachment size limits and large emails
-
β³ Basic IMAP mailboxes
- β³ π Basic IMAP without the PHP extension (#257)
- β³ π οΈ Basic IMAP client β Developer Guide
- β³ π§ͺ Basic IMAP client β Testing and verifying
- β³ π οΈ Basic IMAP - Sent copy - Developer Guide (#270)
- β³ π οΈ Verifying a mail folder β Developer Guide
- β³ π οΈ Mailbox providers β Developer Guide
- β³ π οΈ How Gmail mail collection works β Developer Guide
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Case reports - a ticket and its work as one PDF
- β³ π Scheduled work in your own calendar
- Problem Management
-
Tasks
- β³ π οΈ Task board and subtasks β Developer Guide
- β³ π§βπ€βπ§ People on a task ("Involved")
- β³ π Repeating tasks
- β³ π’ Moving a task between companies
- π Projects
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Leases
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π§© Asset type layouts
- β³ π Inventory freshness
- β³ π Linked configuration item
- β³ π Connected assets
- β³ π Asset review
-
β³ π·οΈ QR asset labels
- β³ π οΈ QR asset labels β Developer Guide
- β³ π Asset label designer - design (#229)
- β³ π· Asset scanning β Developer Guide
- β³ π οΈ Asset tag numbering β Developer Guide
- β³ π οΈ Asset reconciliation β Developer Guide
- β³ π οΈ Reconciliation, tags + labels - review (#164)
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ πͺ Hyper-V and Failover Clusters
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Starters & leavers
- Domains
- ποΈ Files
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
-
CMDB
- β³ π οΈ CMDB β Developer Guide
- β³ π οΈ Impact analysis β Developer Guide
- β³ π οΈ Data quality β Developer Guide
- β³ π Linked assets
- β³ π Linking an asset to its CI - design (#201)
- β³ π Discovery and redundancy-aware impact - design (#199)
- β³ π οΈ Relationship wording - review (#297)
- Network Mapper
-
Workflows
- β³ π οΈ Workflow engine β Developer Guide
- β³ π οΈ Workflows for tasks β Developer Guide
- β³ π οΈ Workflow notes on a ticket β Developer Guide
- β³ π οΈ Workflow category routing β Developer Guide
- β³ β° Time-Based Triggers
- β³ πͺ Webhooks
- β³ π¬ Webhook Message Formats
- β³ π HTTPS Certificates & CA Bundles
- β³ π οΈ SSL Verification β the Global Setting
- β³ π Workflow & Webhook Pitfalls
- Issue trackers (Jira, Azure DevOps)
- π‘ Zabbix (monitoring)
- π¬ Microsoft Teams alerts
- π 3CX (phone system)
-
System
- β³ π οΈ Preferences β Developer Guide
- β³ π§Ή Maintenance
- β³ π Finding people in the Analysts list
- β³ πͺ When an analyst leaves
- β³ π οΈ System β Branding β Developer Guide
- β³ π©Ί Page health (D019) β Developer Guide
- β³ β Feature Bingo
- β³ π·οΈ Cost centres
- β³ π· Photo Album
- β³ Demo Data
- β³ π οΈ Demo Data β Developer Guide
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ One company supporting another
- β³ π’ One board across every company
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)
- What this is
-
π Bugs resolved
- β³ π #301 CSAT delay was never applied
- β³ π #281 Comments imported from Jira never arrived
- β³ π #288 A change failed to open with Enforce approvals on
- β³ π Long non-Latin articles never got an embedding
- β³ π« #280 Email lookups scanned the whole table
- β³ β #266 Dragging a task told nobody
- β³ π #255 Article previews showed HTML codes
- β³ π #252 A link to a Knowledge article did not open
- β³ π #254 PDFs could not print Polish
- β³ π #250 Clicking a folder left the open article on screen
- β³ π« #260 The dashboard cog did nothing
- β³ π« #248 A department rule never matched on four routes
- β³ π A directory account could be matched to someone else
- β³ π« #244 Reply went to the assigned analyst
- β³ π A lookup answer could name any record
- β³ π« #227 A mailbox could not be deleted
- β³ π« #226 A picture on a note could not be closed
- β³ π» #222 The Assets page never loaded in 4.3.0
- β³ βοΈ #214 A "contains" condition did not match capitals
- β³ βοΈ #216 A workflow email from a chosen mailbox always failed
- β³ π³ The Docker image was missing PHP extensions
- β³ π A failed DNS lookup read as a missing record
- β³ π A link could change data
- β³ π Access outlived its removal
- β³ π Knowledge articles could run scripts
- β³ π Knowledge writes ignored permissions
- β³ π Company boundaries (Oct 2026)
- β³ π Smaller gaps (Oct 2026)
- β³ π Escaped text could leave its attribute
- β³ π Task descriptions were not cleaned
- β³ π Task documents ignored company scope
- β³ π Repeating tasks ignored company scope
- β³ π¬ #183 Mailbox checks overlapped
- β³ π #188 Transfer lock shown off when unknown
- β³ β #184 Subtask names and Overdue
- β³ π§© #186 Teams: the buttons did nothing
- β³ π Text in a change could run script
- β³ π AI settings changed without the AI permission
- β³ πͺ #178 Deleting an analyst failed
- β³ π« A team with no departments showed 0 tickets
- β³ π Projects: adding a phase did nothing on Save
- β³ π Leavers could still sign in
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96