REST API Tickets - edmozley/freeitsm GitHub Wiki
The complete usage guide for the Tickets module of the REST API β every URL, method, parameter and response shape. This mirrors the interactive documentation built into the product (System β API β Documentation), where each endpoint also has a live "Try it" tester.
Note
This is the first of the per-module API guides. As the API grows (assets, problems, changesβ¦), each module gets its own page like this one.
1. Create a key under System β API (grant it only what it needs β for the example below, just Tickets: create). 2. Copy the key when it's shown β that's the only time you'll see it. 3. Call the API:
curl -X POST "https://your-server/api/v1/tickets" \
-H "Authorization: Bearer fitsm_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"subject": "Disk space low on SQL01",
"requester_email": "[email protected]",
"description": "C: drive below 5% free.",
"priority": "High"
}'That's a ticket in the inbox β with the description as its opening message, an audit entry, and any matching workflows already fired. π
| Base URL |
https://your-server/api/v1 β shown on the System β API page for your install. No mod_rewrite? Use β¦/api/v1/index.php/<path> instead; identical behaviour. |
| Authentication |
Authorization: Bearer fitsm_β¦ on every request (X-Api-Key: fitsm_β¦ also accepted). |
| Format | JSON in, JSON out (UTF-8). Timestamps are always ISO 8601 UTC: 2026-07-02T22:18:22Z. |
| Success |
{"data": β¦} β plus "meta" (pagination) on list endpoints. Status 200, or 201 on creation. |
| Errors |
{"error": {"code": "β¦", "message": "β¦"}} with a real status: 401 bad key Β· 403 missing permission (named in the message) Β· 404 not found/not visible Β· 405 wrong verb Β· 409 conflict Β· 422 invalid input Β· 429 rate limited. |
| Rate limit | 60 requests/min per key by default (per-key override available). Watch X-RateLimit-Remaining; a 429 means slow down. |
| Permissions | Each endpoint below shows its required permission as a badge, e.g. π tickets.read. |
| Companies (MSP) | On multi-company installs a key only sees its scoped companies. null company fields mean the Default company. Single-company installs can ignore everything about company_id. |
| PATCH semantics | Send only the fields you want to change. Omitted = untouched; explicit null (or "") = cleared. Re-sending the same PATCH is safe β no duplicate audit rows or emails. |
| Name or id | Lookups accept either: "priority": "High" or "priority_id": 3. |
Verifies authentication and returns what the key can do β its name, the analyst it acts as, its permission map, company scope and expiry. Any valid key can call it.
{ "data": { "ok": true,
"key": { "name": "Monitoring", "acts_as": "Administrator",
"permissions": { "tickets": ["read", "create"] },
"companies": null, "expires_at": null },
"server_time": "2026-07-02T22:18:09Z" } }The API version and the full list of routes.
Trash is excluded unless deleted=true. Results are limited to the key's company scope.
| Parameter | Description |
|---|---|
state |
open | closed | all (default all) |
status / status_id
|
Filter by status name (e.g. In Progress) or id |
priority / priority_id
|
Filter by priority name or id |
assigned_analyst_id |
Filter by assignee Β· unassigned=true finds unassigned tickets |
requester_email / user_id
|
Filter by requester |
ticket_type_id Β· origin_id Β· department_id Β· company_id
|
More id filters |
q |
Search subject and ticket number |
created_since Β· created_before Β· updated_since Β· closed_since
|
ISO 8601 date-time bounds |
deleted |
true lists the trash instead |
sort |
created_at, updated_at, closed_at, id, subject, ticket_number, priority, status β prefix - for descending (default -created_at) |
page / per_page
|
Pagination β default 1 / 25, max per_page 100 |
GET /tickets?state=open&priority=High&updated_since=2026-07-01T00:00:00Z&sort=-updated_at&per_page=50
{ "data": [ { "id": 139, "ticket_number": "WBK-509-34271",
"subject": "API test - printer jam",
"status": { "id": 2, "name": "In Progress", "is_closed": false },
"priority": { "id": 3, "name": "High" },
"ticket_type": null, "origin": null, "department": null,
"assigned_analyst": { "id": 1, "name": "Administrator" },
"requester": { "id": 173, "email": "[email protected]", "name": "Jane Smith" },
"company": { "id": 1, "name": "Default" },
"first_time_fix": null, "it_training_provided": null,
"created_at": "2026-07-02T22:18:22Z", "updated_at": "2026-07-02T22:18:47Z",
"closed_at": null, "work_start_at": null, "deleted_at": null } ],
"meta": { "page": 1, "per_page": 50, "total": 1, "total_pages": 1 } }Creates a ticket exactly like the UI: the requester is found or created by email, the description becomes the ticket's opening message (so it reads like any other ticket in the inbox), an audit entry is written, and the workflow engine fires ticket.created. Defaults: status Open, priority Normal, unassigned, the key's default company. Returns 201 with the full ticket.
| Field | Required | Description |
|---|---|---|
subject |
β | Ticket subject |
requester_email |
β | Requester's email β found or created automatically |
requester_name |
Display name, used if the requester is new | |
description |
The request text (plain text; becomes the ticket body) | |
status / status_id
|
Override the default Open | |
priority / priority_id
|
Override the default Normal | |
ticket_type_id Β· origin_id Β· department_id
|
Optional classification | |
assigned_analyst_id |
Assign on creation | |
company_id |
File under a specific company (must be in the key's scope) | |
mailbox_id |
Send-from mailbox for later replies (validated against the company) | |
sensitivity |
normal or confidential. A confidential department or mailbox makes it confidential whatever is sent. See Confidential tickets
|
Everything in the list shape plus description_html β the original request body. Every ticket also carries sensitivity: normal or confidential, and workspace: true when someone has turned it into a workspace (false before Database Verification has added the column).
π related β what is on the ticket, and what it is linked to (5.2.0, #328)
So you know which of the endpoints below are worth calling - without calling five of them to find four empty:
"related": {
"counts": { "notes": 3, "messages": 5, "attachments": 2, "forms": 1,
"checklists": 0, "watchers": 2, "time_entries": 0 },
"links": [
{ "type": "task", "id": 41, "name": "Order laptop" },
{ "type": "problem", "id": 7, "reference": "PRB-0007", "name": "VPN drops" },
{ "type": "ticket", "id": 990, "reference": "TKT-0990", "relation": "duplicate_of" },
{ "type": "external_issue", "provider": "jira", "reference": "OPS-12",
"url": "https://example.atlassian.net/browse/OPS-12", "status": "In Progress" }
]
}| counts | One per sub-endpoint: notes (/notes), messages (/thread), attachments (/attachments), forms, checklists, watchers, time_entries. A count is there only if the key has read on that endpoint's permission - a key with tickets.read alone gets {}. |
| links |
type is task, problem, change, project, asset, cmdb_object, ticket or external_issue, with the record's id (to call its own endpoint) and name. A link is there only if the key may read that kind of record, in that company.
|
| ticket links |
relation is from this ticket's point of view: parent_of, child_of, duplicate_of, duplicated_by, related, or merged_into_this (a ticket merged into this one - it is in the trash, GET /tickets/{id} with deleted=true finds it). They carry the ticket number, never the subject - the other ticket may be confidential. |
| external issues | An issue in Jira or another connected tracker: its key, its address and its status there. |
The same related block is on the ticket's Full record webhook (with every count and link - whoever set up the workflow chose to send the record). On GET /tickets it is there only with ?include=related, because a page of 100 tickets would otherwise run 100 sets of counts.
Change the requester (5.2.0, #308): send user_id - a person the key's analyst can see, in the ticket's company. It is always written to the ticket's history, as Requester.
Updates any combination of fields, with full UI parity: closing sets closed_at (and reopening clears it), assignment keeps the owner in sync and sends the assignment email, closing sends the closure email and can auto-trigger a CSAT survey, every change lands in the audit log, and the workflow engine fires ticket.status_changed / ticket.priority_changed / ticket.assigned. Returns the updated ticket.
| Field | Notes |
|---|---|
subject |
Cannot be blanked |
status / status_id
|
By name or id |
priority / priority_id
|
null clears |
ticket_type_id Β· origin_id Β· department_id
|
null clears |
assigned_analyst_id |
null unassigns |
first_time_fix Β· it_training_provided
|
Booleans |
work_start_at |
Scheduled work start, ISO 8601 (null clears) |
company_id |
Move to another company β the key must be scoped to both sides |
sensitivity |
normal or confidential. The only way to make a confidential ticket normal again; moving a ticket into a confidential department makes it confidential |
workspace |
Boolean - More β Make workspace, over the API. Audited as Workspace: off β on. Changing only this fires no workflow and sends no email - it changes how the ticket is shown, not the work. Refused until Database Verification has added the column. Whether anyone actually sees a workspace still depends on the install setting and their own preference - see Ticket workspace |
{ "status": "In Progress", "priority": "High", "assigned_analyst_id": 1 }Warning
A ticket in the trash answers 409 conflict to updates β restore it first.
GET /tickets/{id}, POST /tickets and PATCH /tickets/{id} carry custom_fields: an object keyed by each field's key (shown under each field's name on Tickets β Settings β Custom fields: the name lower-cased with underscores when it was created, e.g. start_date, and it never changes), holding every field this ticket has, null when unanswered. See Custom ticket fields.
| Kind | Value |
|---|---|
| Text, dropdown, web / email address | a string; a dropdown only accepts one of its options |
| Number | a number |
| Yes / no |
true / false (send true, false, 1 or 0) |
| Date |
"2026-11-03"; date and time "2026-11-03T09:00:00"; time "09:00"
|
"custom_fields": { "start_date": "2026-11-03", "username": "amorgan", "remote_worker": true }-
POST: a field the ticket's type requires when logged must be sent, or the call is refused with 422
missing_field. Fields that do not apply to the type, or a value of the wrong kind, are 422invalid_field. -
PATCH: only the keys you send are touched;
""clears one (not one required when logging). A field required before closing that is still empty follows the Mandatory fields rule when the PATCH closes the ticket - and filling it in the same PATCH counts.
Soft-deletes (moves to the trash β same as the UI, fully restorable). Already trashed β 409.
Brings a ticket back from the trash and returns it.
All notes, oldest first β is_internal: true marks private analyst notes.
| Field | Required | Description |
|---|---|---|
text |
β | The note text |
is_internal |
false = public note (default true) |
The note is attributed to the analyst the key acts as. Returns 201.
The full conversation β inbound emails / channel messages (channel is email, whatsapp, β¦) and outbound replies β oldest first, with HTML bodies:
{ "data": [ { "id": 512, "direction": "Inbound", "is_initial": true, "channel": "email",
"subject": "Printer jam", "from": { "address": "[email protected]", "name": "Jane Smith" },
"to": "[email protected]", "cc": null,
"body_preview": "Paper jam error, tray 2.", "body_html": "<p>Paper jam error, tray 2.</p>",
"received_at": "2026-07-02T09:14:03Z" } ] }Everything attached to the ticket: files on its messages and documents on its notes. Inline pictures inside an email body (a signature's logo) are left out.
{ "data": [
{ "source": "message", "source_id": 512, "id": 88, "kind": "file", "name": "quote.xlsx",
"content_type": "application/vnd.ms-excel", "size_bytes": 20480, "created_at": "2026-11-13T09:14:03Z" },
{ "source": "note", "source_id": 301, "id": 54, "kind": "file", "name": "diagram.pdf",
"content_type": "application/pdf", "size_bytes": 1234, "created_at": "2026-11-13T10:02:11Z" }
] }source_id is the message id from /thread or the note id from /notes. kind is file, link (with its url) or files - a link to a file in the Files module, given without its name because the API does not check Files permissions. This lists what is there; it does not download it.
Every audited change with old/new values and who made it:
{ "data": [ { "id": 120, "field": "Status", "old_value": "Open", "new_value": "In Progress",
"analyst": { "id": 1, "name": "Administrator" },
"created_at": "2026-07-02T22:18:47Z" } ] }The live SLA computation β the same engine the UI uses: response/resolution targets from the ticket's priority, elapsed and remaining business minutes against the SLA calendar (with paused statuses honoured), percentages and breach flags.
Analysts watching a ticket are told about it as if it were theirs, through their own notification settings, for the events chosen under Tickets β Settings β General β Watching tickets.
{ "data": [ { "analyst": { "id": 7, "name": "Sam Patel" }, "added_by": { "id": 3, "name": "Ed" }, "added_at": "2026-11-11T10:02:00Z" } ] }Active analysts only. A deactivated analyst is never listed or notified; whether their watches are kept for if they come back or removed is Tickets β Settings β General β When a watcher is deactivated (5.0.0).
{ "analyst_id": 7 } - or no body for the key's own analyst. 201 when added, 200 when they were already watching (it returns the watcher either way). 422 invalid_field when the analyst does not exist, is inactive, or cannot see the ticket.
Takes them off. 404 when they were not watching.
The forms on a ticket - the ticket twin of the forms a task needs, with the same shapes. Empty while Tickets β Settings β Checklists β Forms on tickets is off. Both need the Tickets and Forms modules switched on.
{ "data": [ { "id": 7, "form_id": 88, "title": "New starter equipment", "required": true, "requirement": "block",
"submission_id": 512, "submitted_by": "Sam Patel", "submitted_at": "2026-11-11T10:02:00Z",
"has_draft": false, "revision": 3,
"answers": [ { "field_id": 901, "question": "Device", "answer": "Laptop", "exception": false } ] } ] }requirement is block, warn or optional. answers is absent (not empty) unless the key's analyst may read form answers: Forms access, or the Read the form answers on tickets you can see permission.
{ "data": { "901": "Laptop", "902": "2026-11-16" }, "client_reference": "starter-1042", "revision": 3 } - the same validation as the Forms page. 201 with { ticket_id, ticket_form_id, submission_id, revision, replayed: false }; the same client_reference again is 200 with replayed: true and the same submission. revision is optional; sent, a save made since is 409. A requirement that is not on this ticket is 404. The key's analyst needs write access to Forms. Submitting again replaces the earlier submission on the ticket, which is kept in Forms.
The checklists on a ticket, and answering their steps. The same shape as on tasks - since #271 tickets and tasks share one checklist engine, so an integration reads and answers a step the same way on either. Writes go through the same rules as the app: a step that asks for a value needs one, a step that asks for a result is answered rather than ticked, and a fail needs a note.
Each change writes the step's note on the ticket - β
Checklist step completed: [New starter] "Asset tag" β Response: AT-4471 (via the API) - as the key's analyst, exactly as a tick in the ticket's panel does (and it fires ticket.note_added the same way).
{ "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 }
] } ] }-
asks_foris how the step is answered:tick,value(a tick with a text answer) orresult(pass,failorna). -
closureis what closing / completing does with mandatory steps still outstanding, after the company setting:warn(allowed, and recorded) orblock(refused). A step with a result - including a fail - is not outstanding.
Send either completed or result:
| Body | Does |
|---|---|
{ "completed": true } |
Tick a tick step |
{ "completed": true, "value": "AT-4471" } |
Tick a value step with its answer |
{ "completed": false } |
Untick - clears the value and any result |
{ "result": "pass" } / { "result": "na" }
|
Answer a result step |
{ "result": "fail", "note": "Job 42 failed overnight" } |
A fail, with what went wrong (required) |
Returns the step as it now stands. Errors: 404 - the ticket or step does not exist, is outside the key's companies, or the step is on another ticket; 422 missing_field - a value or a fail's note is missing, or a result step was sent completed; 422 invalid_field - neither or both of completed and result, a completed that is not true / false, or an unknown result.
Active time entries with analyst, minutes, notes and timestamp.
| Field | Required | Description |
|---|---|---|
minutes |
β | Positive integer |
notes |
What the time was spent on | |
entry_at |
When the work happened, ISO 8601 (default now) |
Soft-deletes the entry (same as the UI).
End users who raise tickets (distinct from analysts). POST /tickets creates requesters automatically β these endpoints are for lookups and directory sync.
| Parameter | Description |
|---|---|
q |
Search email and names |
email |
Exact email lookup |
page / per_page
|
Pagination |
One requester, plus their ticket counts (within the key's company scope):
{ "data": { "id": 173, "email": "[email protected]", "display_name": "Jane Smith",
"preferred_name": null, "created_at": "2026-07-02T22:18:22Z",
"tickets": { "total": 12, "open": 2 } } }email (required, unique β duplicate β 409), display_name, preferred_name, cost_centre_id (3.5.0 β an active cost centre of their company; no company = the Default company's; 422 otherwise). Returns 201.
Change email (uniqueness enforced), display_name, preferred_name, is_active (3.5.0 - false = marked as left: kept on record, can't sign in to the portal by any route, and an open portal session ends), cost_centre_id (null clears; one they already hold may be kept after it's made inactive). Changing tenant_id while they hold a cost centre needs a cost_centre_id of the new company (or null) in the same request.
Every requester carries cost_centre: {id, code, name} or null (3.5.0).
Everything an integration needs to build valid writes β grab these once and cache them.
| Endpoint | Permission | Returns |
|---|---|---|
π’ GET /analysts
|
π analysts.read |
Active analysts (id, name, email, is_active) β assignment targets. ?email= / ?include_inactive=1 and creating analysts and setting their access: see REST API: Analysts
|
π’ GET /companies
|
π companies.read |
The companies this key can see (id, name, is_default) |
π’ GET /statuses
|
π reference.read |
Statuses with is_closed, is_default, pauses_sla, colour
|
π’ GET /priorities
|
π reference.read |
Priorities with SLA response/resolution targets (minutes) |
π’ GET /ticket-types
|
π reference.read |
Ticket types β pass ?company_id= to apply that company's add/hide overrides |
π’ GET /origins
|
π reference.read |
Ticket origins β ?company_id= as above |
π’ GET /departments
|
π reference.read |
Departments |
A typical "alert β ticket β auto-resolve" round trip using a key with just tickets: read, create, update:
B="https://your-server/api/v1"; K="Authorization: Bearer fitsm_β¦"
# 1. Alert fires β raise a High ticket
TICKET=$(curl -s -X POST "$B/tickets" -H "$K" -H "Content-Type: application/json" -d '{
"subject": "[ALERT] SQL01 disk C: below 5%",
"requester_email": "[email protected]",
"description": "Datastore free space 4.2%. Threshold 5%.",
"priority": "High"}' | jq -r '.data.id')
# 2. Alert recovers β find our open ticket and close it with a note*
curl -s -X PATCH "$B/tickets/$TICKET" -H "$K" -H "Content-Type: application/json" \
-d '{"status": "Closed"}'* closing via the API sends the closure email and fires your workflows, exactly as if an analyst clicked it. Give the key ticket_notes: create too if you want the recovery details on the ticket.
Under the hood: REST API β How It Works. Keys & permissions: System β API in the product (System module). Live tester: System β API β Documentation.