REST API Changes - edmozley/freeitsm GitHub Wiki

🟠 REST API: Changes

The complete usage guide for the Change Management module of the REST API β€” every URL, method, parameter and response shape, including the full CAB approval workflow. Mirrors the interactive documentation at System β†’ API β†’ Documentation (with its live "Try it" tester).

[!NOTE] New to the API? The basics table on REST API: Tickets covers base URL, authentication, the response envelope, error codes, rate limits and PATCH semantics β€” identical across all modules.

What's distinctive about Changes:

🏒 Company-scoped Every change belongs to a company (changes.tenant_id; none recorded means Default), and a key's company scope restricts every list and every read and write by id - a change outside it is a 404. Matches the UI.
🎲 Risk is computed Send risk_likelihood and risk_impact_score; the API computes score = likelihood Γ— impact and the level from the install's risk policy, exactly like the UI. On the built-in 5 Γ— 5 (1–5 each): ≀4 Low Β· ≀9 Medium Β· ≀15 High Β· ≀20 Very High Β· else Critical. A saved policy has its own levels, words and matrix - GET /change-risk-policy returns it, and each change's risk.policy says which one it was rated under (#246, Change risk policies).
πŸ—³οΈ CAB voting works over the API The key's acts-as analyst can vote, and the auto-transition mechanics are the UI's own: any required Reject sends the change back to Draft; the all/majority threshold of required members flips it to Approved.
🧾 Strict where it matters Unknown status/type/priority/impact names are a 422 (the UI silently falls back to defaults β€” machines deserve an error).

πŸš€ Quick start

Raise a change, staff its CAB, submit it, and approve it β€” entirely over the API:

B="https://your-server/api/v1"; K="Authorization: Bearer fitsm_…"

# 1. Create (Draft by default; risk computed server-side)
CHG=$(curl -s -X POST "$B/changes" -H "$K" -H "Content-Type: application/json" -d '{
  "title": "Replace SFP on core switch uplink",
  "change_type": "Normal", "priority": "High",
  "reason_for_change": "Fixes PRB-00005 (recurring VPN drops).",
  "risk_likelihood": 2, "risk_impact_score": 4,
  "cab_required": true, "cab_approval_type": "all",
  "work_start_at": "2026-07-05T06:00:00Z", "work_end_at": "2026-07-05T08:00:00Z"
}' | jq -r '.data.id')

# 2. Staff the CAB
curl -s -X POST "$B/changes/$CHG/cab" -H "$K" -H "Content-Type: application/json" \
  -d '{"members": [{"analyst_id": 1, "is_required": true}, {"analyst_id": 2, "is_required": false}]}'

# 3. Submit for approval
curl -s -X PATCH "$B/changes/$CHG" -H "$K" -H "Content-Type: application/json" -d '{"status": "Pending Approval"}'

# 4. A CAB member's key casts the deciding vote β†’ auto-Approved, workflow fires
curl -s -X POST "$B/changes/$CHG/cab/vote" -H "$K" -H "Content-Type: application/json" \
  -d '{"vote": "Approve", "comment": "Rollback plan looks solid."}'

Every step lands in the change's audit trail exactly as if it happened in the UI. 🧾


🟠 Changes

🟒 GET /changes β€” list / search Β  πŸ”‘ changes.read

Parameter Description
state open | closed | all (default all) β€” closed = Rejected/Completed/Failed/Cancelled
status / status_id e.g. Pending Approval, by name or id
change_type / change_type_id Standard / Normal / Emergency
priority / priority_id Β· impact / impact_id By name or id
category_id Β· requester_id Β· assigned_to_id Β· approver_id More id filters
cab_required true / false
risk_level The stored word: on the built-in policy Low, Medium, High, Very High, Critical; on a saved policy its own words
q Search title β€” CHG-0042 (or chg42) finds a change by its reference
work_start_from / work_start_to πŸ“… Scheduled-work window (ISO 8601) β€” the calendar shape
created_since / modified_since ISO 8601 date-time bounds
sort created_at (default, desc), modified_at, id, title, work_start_at, risk_score, priority, status β€” prefix - for descending
page / per_page Pagination β€” default 1 / 25, max 100

The change shape (lists; detail adds bodies, PIR, attachments, linked problems):

{ "id": 11, "change_number": "CHG-0011",
  "title": "Replace SFP on core switch uplink",
  "change_type": { "id": 2, "name": "Normal" },
  "status":      { "id": 4, "name": "Approved", "is_closed": false },
  "priority":    { "id": 3, "name": "High" },
  "impact":      { "id": 2, "name": "Medium" },
  "category":    null,
  "requester": null, "assigned_to": { "id": 1, "name": "Administrator" }, "approver": null,
  "approval_at": "2026-07-02T23:30:32Z",
  "cab":  { "required": true, "approval_type": "all" },
  "risk": { "likelihood": 2, "impact": 4, "score": 8, "level": "Medium",
            "policy": { "version_id": null, "builtin": true, "name": "Built-in 5 x 5", "levels": { "likelihood": 5, "impact": 5 } } },
  "schedule": { "work_start_at": "2026-07-05T06:00:00Z", "work_end_at": "2026-07-05T08:00:00Z",
                "outage_start_at": null, "outage_end_at": null },
  "created_by": { "id": 1, "name": "Administrator" },
  "created_at": "2026-07-02T23:30:12Z", "modified_at": "2026-07-02T23:30:32Z" }

πŸ”΅ POST /changes β€” create Β  πŸ”‘ changes.create

Only title is required. Omitted lookups get the module defaults (Normal / Draft / Medium / Medium); a creation entry is written to the audit trail. Returns 201.

Field group Fields
Lookups (name or id) change_type Β· status Β· priority Β· impact Β· category_id
People (analyst ids) requester_id Β· assigned_to_id Β· approver_id
Schedule (ISO 8601) work_start_at Β· work_end_at Β· outage_start_at Β· outage_end_at
Plans (long text) description Β· reason_for_change Β· risk_evaluation Β· test_plan Β· rollback_plan
Risk risk_likelihood Β· risk_impact_score (1 to the policy's levels; 1–5 on the built-in) β†’ score + level computed. On a change rated under an earlier policy, unchanged ratings leave it there; different ratings - or risk_reassess: true - re-assess it under the current policy, and then both are required (else 422)
CAB cab_required (bool) Β· cab_approval_type (all | majority)

🟒 GET /changes/{id} β€” get one Β  πŸ”‘ changes.read

Everything above plus description, reason_for_change, risk.evaluation, test_plan, rollback_plan, the PIR block (review, was_successful, actual_start_at/end_at, lessons_learned, follow_up), the attachments list (name/size/type β€” files are managed in the UI), and linked_problems (problems this change fixes).

Since 3.8.0 it also carries approval - the current approval round (id, round_no, route, state, is_independent, content_changed) and current_version, the fingerprint of the change's material content. Send approval.id and approval.current_version back as approval_id and content_hash when you vote or decide, so the record names what you read. status carries its stage (draft, awaiting, approved, implementing, done, stopped).

🟠 PATCH /changes/{id} β€” update Β  πŸ”‘ changes.update

Everything creatable is patchable, plus the PIR fields (pir_was_successful, pir_actual_start_at, pir_actual_end_at, pir_lessons_learned, pir_follow_up). Send only what changes; null clears; identical PATCHes are idempotent.

Audit parity with the UI: human field labels ("Title", "Status", "Work Start", …), display names for lookups, (empty) placeholders, status changes marked status_change. The long plan bodies keep every version in full (3.8.0) - see GET /changes/{id}/revisions - with a one-line text_edit audit row pointing at each. Changing risk inputs recomputes score + level. A genuine transition into Approved fires the change.approved workflow event.

Enforce approvals (Change Management β†’ Settings β†’ Approvals, 3.8.0) applies to the API exactly as to the screen: a PATCH or POST into an Approved, Implementing or Done stage without a current approval is 409 approval_required; changing the approver or CAB settings after submission is 409 approval_locked; a material edit returns 200 with the change back in an awaiting status and a new round. A key acting as an analyst with Read only access to Change Management cannot write (403 read_only), whatever the key's own permissions.

[!TIP] The lifecycle is: Draft β†’ Pending Approval β†’ Approved β†’ Scheduled β†’ In Progress β†’ Completed / Failed (Rejected and Cancelled are terminal). Only the Pending Approval β†’ Approved/Draft leg is automated (by CAB votes); every other transition is an explicit status PATCH, exactly like the UI.

πŸ”΄ DELETE /changes/{id} β€” delete Β  πŸ”‘ changes.delete

[!WARNING] Permanent. Deletes the change, removes its attachment files from disk, and cascades comments, CAB entries, the audit trail, text history and approval records β€” same as the UI. No restore.

Since 3.8.0 a change that has ever been submitted for approval answers 409 change_has_approval_history where the approval rules keep such changes (always in Enforce mode). Cancel it instead.

πŸ”΅ POST /changes/{id}/cancel Β  πŸ”‘ changes.update

Body: {"reason": "…"}. Moves the change to the Stopped status chosen in Settings β†’ Approvals (by default the last one, Cancelled), withdraws any open approval round with the reason, and keeps every record. Returns the change.


πŸ—³οΈ CAB (Change Advisory Board)

🟒 GET /changes/{id}/cab Β  πŸ”‘ change_cab.read

The roster with votes, plus live approval progress:

{ "data": { "cab_required": true, "approval_type": "all",
    "members": [ { "analyst_id": 1, "name": "Administrator", "is_required": true,
                   "vote": "Approve", "vote_comment": "Rollback plan looks solid.",
                   "voted_at": "2026-07-02T23:30:32Z" } ],
    "progress": { "required_total": 1, "required_approved": 1, "required_rejected": 0 } } }

πŸ”΅ POST /changes/{id}/cab β€” set the roster Β  πŸ”‘ change_cab.manage

Body: {"members": [{"analyst_id": 1, "is_required": true}, …]} β€” replaces the roster. Additions, removals and required↔optional switches are diffed and each writes its own audit entry (like the UI). Returns the updated roster.

πŸ”΅ POST /changes/{id}/cab/vote β€” cast a vote Β  πŸ”‘ change_cab.vote

Body: {"vote": "Approve" | "Reject" | "Abstain", "comment": "…", "approval_id": 12, "content_hash": "…"}. The vote is cast as the analyst the key acts as β€” they must be a CAB member who hasn't voted in this round (403 if not a member, 409 if already voted). Since 3.8.0 the change must have an open approval round (409 not_awaiting_approval), and approval_id / content_hash (from GET /changes/{id}) name the version you read: a vote against an edited change is recorded and marked, or refused with 409 approval_stale where the approval rules say so (always in Enforce mode, where both fields are required - 400 missing_field without them).

The auto-transition is the UI's exact mechanics, and only runs while a round is open:

  • πŸ”΄ Any counted required member votes Reject β†’ the change goes to the rejection status chosen in Settings β†’ Approvals (a Draft-stage status by default).
  • 🟒 The threshold of counted required members approve (all = everyone; majority = more than half) β†’ the change moves to the first Approved-stage status, approval_at is stamped, and the change.approved workflow event fires.
  • βšͺ Optional members' votes are recorded but don't count; Abstain never transitions anything. In Enforce mode the change's own people's votes are recorded with counted: false.

The response tells you what happened: {"vote": "Approve", "status_changed": true, "new_status": "Approved", "counted": true, "was_current": true}. new_status is the status's name, which an administrator can rename - read the change's status.stage to know what it means.


βœ… Approvals and text history (3.8.0)

🟒 GET /changes/{id}/approvals Β  πŸ”‘ change_approvals.read

Every approval round, oldest first: route (cab, approver, manual, emergency, override, pre_authorised, legacy), state (open, approved, rejected, withdrawn, superseded), who opened and decided it and when, is_independent, the snapshot (material values, and the revision id of each long field it was given) and every vote with is_counted and was_current.

πŸ”΅ POST /changes/{id}/approval/decide Β  πŸ”‘ change_approvals.decide

Body: {"decision": "approve" | "reject", "note": "…", "approval_id": 12, "content_hash": "…"}. The named approver's decision, as the analyst the key acts as - who must be the change's approver (403 forbidden), independent where the rules require it (403 not_independent), on a change with an open round and no CAB (409). Same version check as a vote.

🟒 GET /changes/{id}/revisions Β  πŸ”‘ change_revisions.read

Every saved version of the long-text fields, newest first, without content: id, field, editor_name, source (ui, api, system, upgrade for the "as found" baseline, deleted for a deleted comment), is_baseline, created_at, content_hash, length. ?field=test_plan filters.

🟒 GET /changes/{id}/revisions/{revision_id} Β  πŸ”‘ change_revisions.read

One version with its full content, and previous_id - the version before it. The content is author-written HTML for the rich fields: sanitise it before you render it.

[!NOTE] The new permissions start unticked on every existing key. A change can be raised from an approved standard-change template with POST /changes and template_version_id; pre-authorisation is decided on submission, as in the UI. Templates themselves are written and approved in the UI only.


πŸ’¬ Comments & history

🟒 GET /changes/{id}/comments Β  πŸ”‘ change_comments.read

Comments newest first, with the author.

πŸ”΅ POST /changes/{id}/comments Β  πŸ”‘ change_comments.create

Body: {"text": "…"}. Always internal, attributed to the acts-as analyst, with a preview written to the audit trail. Comments can't be edited (parity with the UI) β€” only added and deleted.

πŸ”΄ DELETE /changes/{id}/comments/{comment_id} Β  πŸ”‘ change_comments.delete

Removes the comment from the timeline. Since 3.8.0 its text, author and date are kept as a revision (field=comment) and the deletion is audited.

🟒 GET /changes/{id}/audit Β  πŸ”‘ change_audit.read

Every audited event β€” field changes, status changes, CAB roster edits, votes, comment previews β€” with action (field_change / status_change / cab_vote / comment, and since 3.8.0 text_edit, attachment, comment_deleted, evidence_export), old/new values and the analyst, newest first.


πŸ“Ž Evidence attachments (3.12.0, #194)

A pipeline can attach evidence - a test report, a scan, a sign-off - to a change without being able to change anything else. The key needs change_attachments.create (and .read to list and download); it does not need, and is not given by, changes.update, so it cannot edit, approve, vote, cancel or delete. Read only access to Change Management still refuses an upload, and company scope applies as everywhere.

🟒 GET /changes/{id}/attachments Β  πŸ”‘ change_attachments.read

Every attachment on the change - added in the app or over REST - with file_name, file_size, file_type, sha256, uploaded_by_id, uploaded_at, client_reference and a content_url. Add ?client_reference=report-1 to ask "did my upload arrive?" without sending it again: the answer is the matching attachment, or an empty list.

πŸ”΅ POST /changes/{id}/attachments Β  πŸ”‘ change_attachments.create

multipart/form-data with a file part and an optional client_reference (1-128 letters, digits, . _ : -; case-sensitive). The same file rules as the app apply - allowed types, the content must match its extension, the size limit - and the upload writes the same History line. Returns 201.

Retries are safe. Send the same client_reference with the same file name and the same bytes and you get the original attachment back with 200; nothing is stored twice. The same reference with a different file is a 409 - a reference names one piece of evidence. References are kept per change and per analyst behind the key, so a rotated key can still retry, and two integrations cannot collide.

curl -X POST "$BASE/api/v1/changes/42/attachments" -H "Authorization: Bearer $KEY" \
  -F "[email protected]" -F "client_reference=build-1873-scan"

🟒 GET /changes/{id}/attachments/{attachment_id}/content Β  πŸ”‘ change_attachments.read

The original bytes, as a download (application/octet-stream). Where the file is stored on the server is never shown.

[!NOTE] Contributed by @Dr4inder in PR #196 and merged with fixes - see the review. The app's own upload now goes through the same code (ChangesService::uploadAttachment()).


πŸ“š Reference data

Under the shared πŸ”‘ reference.read permission:

Endpoint Returns
🟒 GET /change-statuses Draft, Submitted, Pending Approval, Approved, Rejected, Scheduled, In Progress, Completed, Failed, Cancelled β€” with is_closed, is_default, colour
🟒 GET /change-types Standard, Normal, Emergency (plus custom)
🟒 GET /change-priorities Low, Medium, High, Critical
🟒 GET /change-impacts Low, Medium, High
🟒 GET /change-categories Your configured categories (empty until set up)
🟒 GET /change-risk-policy The risk policy new ratings use: likelihood and impact levels (value, label, criteria), results (label, colour), and matrix - matrix[likelihood - 1][impact - 1] is an index into results. builtin: true for the built-in 5 Γ— 5. Needs reference.read (#246)

πŸ§ͺ Worked example: change calendar feed

A read-only key (changes: read) feeds an external calendar or maintenance-window banner:

B="https://your-server/api/v1"; K="Authorization: Bearer fitsm_…"

# Everything scheduled in the next 14 days, soonest first
curl -s -H "$K" "$B/changes?work_start_from=$(date -u +%Y-%m-%dT%H:%M:%SZ)&work_start_to=$(date -u -d '+14 days' +%Y-%m-%dT%H:%M:%SZ)&sort=work_start_at&per_page=100" \
  | jq -r '.data[] | "\(.change_number)  \(.schedule.work_start_at)  [\(.risk.level // "-")]  \(.title)"'

And a pipeline check before deploys β€” is anything risky approved for tonight?

curl -s -H "$K" "$B/changes?status=Approved&risk_level=High" | jq '.meta.total'

Under the hood: REST API β€” How It Works Β· Other modules: Tickets Β· Assets Β· Problems Β· Keys & permissions: System β†’ API (System module) Β· Module docs: Change Management.