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_atis stamped, and thechange.approvedworkflow 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 /changesandtemplate_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.