Skip to content

Checklists REST API Developer Guide

Ed Mozley edited this page Nov 11, 2026 · 2 revisions

πŸ› οΈ Checklists in the 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.


1. The files

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).


2. The routes, permissions and module map

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 #273

Several 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.


3. How a request flows

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.

The record gate and company scope

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.

Who the change is made as

// 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).


4. Reading: GET …/checklists

// 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;
        }
    }

The SQL that reads a ticket's checklists

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 SQL that reads a task's checklists

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 shape

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 }
    ] } ] }

5. Answering a step: PATCH …/checklists/steps/{step_id}

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);
}

The 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.

The handler

// 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:

  1. 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 in task_checklist_items, finds nothing and returns 0.
  2. completed must be a real JSON boolean. "yes", 1 or "true" is a 422. The in-app endpoints use !empty(...), which is fine for a form post but too loose for a machine.
  3. The service's return value is ignored. toggleTicketItem() returns ticketItemRow(), which has no is_mandatory, suggested_role or description. The handler re-reads the parent's checklists and answers the step from that, so a PATCH returns exactly the step a GET would.
  4. 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);
    }

6. The service methods it calls

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.

Ticking and unticking a ticket step

// 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 result step cannot be ticked with completed: true until it has a result - 422 missing_field. Answer it with result instead.
  • A value sent to a tick step 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 value step 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;
    }

Recording a ticket step's result

    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.

The task twins

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.


7. "via the API"

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.

On a ticket: the step note

// 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.

On a task: the step history

    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 #273

tasks.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.


8. Errors and HTTP statuses

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": [].


9. Why one shape for tickets and tasks

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

10. spec.json, OpenAPI and the checks

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:

  1. Routes in routes.php, permissions in permissions.php - and, for a switchable module, modules.php (section 11, trap 1).
  2. Catalogue entries in spec.json (php api/v1/dev/openapi_stub.php scaffolds them).
  3. Typed schemas brought in line with live responses (openapi_fix.php <read_key>, then openapi_verify.php <read_key>).
  4. The invariants: php api/v1/lib/openapi_check.php. It fails on:
    • drift - a route with no spec.json entry or the reverse (it probes each path with {…} replaced by 1, so /tickets/1/checklists/steps/1 must match the route regex);
    • a dangling $ref (ChecklistStep must be defined);
    • duplicate or missing operationIds; an operation with no responses;
    • an empty array where an object belongs;
    • nullable without a type;
    • a malformed catalogue entry (method, path, summary, perm, each param's name and in of path / query / body).
  5. Meta-schema conformance: php api/v1/dev/jsonschema4.php against oas-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.


11. Traps

1. A new permission resource needs the module map

// 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.

2. apiLoadTicket() and apiLoadTask() take their arguments in a different order

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.

3. Gate the parent, then the child

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.

4. ActorContext's default source is 'api'

// 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.

5. Do not answer with the service's return value

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.

6. The same keys is not the same values

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.

7. A non-scalar value, note or result is cast to "Array"

(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().

8. The API needs the Checklists module for task checklists; the task panel does not

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.


12. Tests

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.


Related

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Responses to feedback

Blue sky thinking

Bugs resolved

Pull requests

Links

Clone this wiki locally