-
Notifications
You must be signed in to change notification settings - Fork 15
REST API
FreeITSM has a versioned, public REST API at /api/v1/ for integrations β monitoring tools, RMM platforms, scripts, portals, anything that can make an HTTP request. This page explains the machinery: every file, the request lifecycle, security, error handling, parameters, JSON building, pagination β the lot.
Note
Looking for what you can call rather than how it works? Each module gets its own usage guide β Tickets, Assets, Problems, Changes, Knowledge, Tasks, CMDB, Contracts, Calendar, Software, Service Status, Morning Checks, Forms, Workflow and Network Mapper so far. The interactive version is built into the product at System β API β Documentation β a searchable three-pane workbench with live code in seven languages and live responses; how to use it. A machine-readable OpenAPI 3.0 specification is served at /api/v1/openapi.json for Postman, client generators and contract tests.
Design goals:
| Goal | How it's met |
|---|---|
| π§ Don't touch what works | The legacy api/external/ ingest endpoints and their plaintext apikeys table are completely untouched. v1 is a separate surface with its own key store. |
| π’ Versioned | Everything lives under /api/v1/. A future breaking change becomes /api/v2/ alongside it β old integrations keep working. |
| ποΈ Granular by default | Keys start with zero permissions; every resource Γ action is granted explicitly. |
| πͺ Write parity with the UI | A ticket touched via the API is indistinguishable from one touched in the UI β audit rows, template emails, CSAT, workflow events all fire. |
| π§― Machine-friendly failures | Real HTTP status codes, structured JSON errors, never an HTML stack trace. |
flowchart TD
A[Client request<br/>GET /api/v1/tickets/42] --> B{.htaccess<br/>mod_rewrite?}
B -- yes --> C[Rewritten to<br/>index.php/tickets/42]
B -- "no rewrite module" --> C2[Client calls<br/>index.php/tickets/42 directly]
C --> D[lib/bootstrap.php<br/>config Β· DB Β· JSON+CORS headers Β· exception handler]
C2 --> D
D --> E{OPTIONS<br/>preflight?}
E -- yes --> F[204 No Content]
E -- no --> G[lib/auth.php Β· apiAuthenticate]
G --> H{Key valid,<br/>active, not expired?}
H -- no --> I[401 / 403 JSON error]
H -- yes --> J[Rate limit check<br/>api_key_rate_limits]
J -- over limit --> K[429 + X-RateLimit headers]
J -- ok --> L[Router matches<br/>method + path regex]
L -- no match --> M[404 unknown endpoint<br/>or 405 + Allow header]
L -- match --> N[apiRequirePermission<br/>resource.action]
N -- missing --> O["403 β names the exact<br/>permission that's missing"]
N -- granted --> P[Handler in resources/*.php<br/>tenancy scope + validation + SQL]
P --> Q["apiRespond β {data, meta}<br/>with real HTTP status"]
Every request passes through the same five gates β routing β authentication β rate limit β permission β tenancy β before a single line of business logic runs.
api/v1/
βββ .htaccess β clean URLs + Authorization header pass-through
βββ index.php β front controller: path resolution, route table, dispatch
βββ lib/
β βββ bootstrap.php β boot: config, headers, CORS, last-resort error handler
β βββ response.php β JSON envelope, error helper, body/date/pagination parsing
β βββ permissions.php β THE permission catalog (single source of truth)
β βββ auth.php β key auth, rate limiting, permission + company enforcement
βββ resources/
βββ tickets.php β the tickets module surface
βββ assets.php β the assets module surface
βββ problems.php β the problem-management surface
βββ changes.php β the change-management surface (incl. CAB voting)
βββ knowledge.php β the knowledge-base surface (articles, versions, recycle bin)
βββ tasks.php β the kanban tasks surface (incl. board moves)
βββ cmdb.php β the CMDB surface (classes, objects, relationships, impact)
βββ contracts.php β contracts, term tabs, suppliers + contacts (RFP Builder excluded)
βββ calendar.php β team-calendar events (naive-local datetimes; generated events read-only)
βββ software.php β inventory reads + licences CRUD w/ computed compliance
βββ service_status.php β health board (derived status) + monitoring-driven incidents
βββ morning_checks.php β daily checks: definitions, day board, result upsert
βββ forms.php β forms + version chains + submissions (form.submitted event)
βββ users.php β requesters (end users)
βββ reference.php β statuses, priorities, types, origins, departments, analysts, companies
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php/$1 [QSA,L]
</IfModule>
<IfModule mod_setenvif.c>
SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
</IfModule>Three jobs, each with a safety story:
-
Clean URLs.
/api/v1/tickets/42is silently rewritten to/api/v1/index.php/tickets/42.QSAkeeps the query string, and the twoRewriteCondlines make sure real files (likeindex.phpitself) are never rewritten. -
Graceful degradation. The whole block is wrapped in
<IfModule mod_rewrite.c>β on a server without mod_rewrite the directives are ignored instead of throwing a 500. The API stays fully usable at/api/v1/index.php/tickets/42because the router readsPATH_INFOeither way. Nothing else in FreeITSM uses mod_rewrite, so the API couldn't assume it exists. -
Authorization header rescue. Some Apache/FastCGI configurations strip the
Authorizationheader before PHP sees it.SetEnvIfre-injects it asHTTP_AUTHORIZATION, andapiExtractKey()checks$_SERVER['HTTP_AUTHORIZATION']/REDIRECT_HTTP_AUTHORIZATIONas fallbacks.
Tip
This is FreeITSM's only rewrite rule, and it's scoped to the api/v1/ folder β it can't affect any other URL in the app.
Everything enters here. In order:
1. Path resolution. The route path comes from $_SERVER['PATH_INFO'], falling back to ORIG_PATH_INFO, falling back to a ?path= query parameter. That triple fallback is why the same file serves rewritten URLs, direct index.php/... URLs, and even servers with AcceptPathInfo quirks.
2. Method override. Clients that can only send GET/POST (old HTTP libraries, some webhook senders) can POST with an X-HTTP-Method-Override: PATCH (or DELETE) header.
3. The route table. One declarative array β the entire API surface at a glance:
// [method, pattern, permission, handler]
['GET', '#^/tickets$#', ['tickets', 'read'], 'apiTicketsList'],
['POST', '#^/tickets$#', ['tickets', 'create'], 'apiTicketsCreate'],
['PATCH', '#^/tickets/(\d+)$#', ['tickets', 'update'], 'apiTicketsUpdate'],Each route declares the permission it needs right next to the handler β you can audit the whole security surface by reading one array. Regex capture groups ((\d+)) become the handler's $params (cast to int).
4. Dispatch. The router walks the table:
- Path matches, method matches β enforce the permission, then call the handler inside a
try/catch(an unexpected exception becomes a logged, generic 500 β never a stack trace). - Path matches but method doesn't β collect the methods that would work and return 405 with a proper
Allow:header. - Nothing matches β 404 with a hint pointing at the documentation page.
5. Meta endpoints. GET / (version + endpoint index) and GET /ping (auth check β echoes the key's name, acts-as analyst, permission map, company scope and expiry) live directly in this file. /ping needs no permission: any valid key may ask "what am I allowed to do?"
- Loads
config.php(which pulls DB credentials from outside the web root),includes/functions.php(connectToDatabase()β PDO with exceptions), andincludes/tenancy.php(the multi-company helpers the auth layer mirrors). - Sets the response headers once:
Content-Type: application/json; charset=utf-8, CORS (Access-Control-Allow-Origin: *+ allowed headers/methods), and anX-FreeITSM-Api-Version: 1fingerprint. - Answers CORS preflight (
OPTIONS) with204before authentication β browsers don't send credentials on preflight, so it must succeed keyless. -
ini_set('display_errors', '0')+ a globalset_exception_handlerβ the two lines that guarantee a broken handler can never leak an HTML error page into a JSON consumer. The real error goes to the PHP error log; the client gets a clean500 {"error":{"code":"server_error",...}}.
| Helper | Job |
|---|---|
apiRespond($data, $status, $meta) |
Emits {"data": ..., "meta": {...}?} with the given HTTP status and exits. meta is only present when there's something to say (pagination). |
apiError($status, $code, $message) |
Emits {"error": {"code": "...", "message": "..."}} and exits. code is a stable machine-readable string; message is for humans. |
apiJsonBody() |
Reads the raw request body. Empty body β []. Present-but-invalid JSON β 400 invalid_json (silent garbage-in is worse than an error). |
apiIsoDate($db) |
DB "2026-07-02 22:18:22" (stored UTC) β "2026-07-02T22:18:22Z". Every timestamp the API emits is ISO 8601 UTC. |
apiParseDate($value, $field) |
The reverse: accepts ISO 8601 (with or without Z) or "YYYY-MM-DD HH:MM:SS", normalises to a UTC DB string, and 400s naming the offending field on garbage. |
apiPagination() |
Reads ?page= / ?per_page= with defaults 1 / 25 and a hard cap of 100, returns [page, perPage, offset]. |
Because the helpers exit, a handler literally cannot fall through and emit two bodies β the first apiRespond/apiError wins.
Important
This envelope is deliberately different from the internal AJAX endpoints (which return HTTP 200 with {success: false} β fine for the app's own JS, terrible for machines). The v1 rule: the HTTP status code carries the outcome, the body carries the detail.
apiV1PermissionCatalog() returns every resource, action and human description the API knows about. Three consumers read this one function:
- π the auth layer β enforcement (
apiRequirePermission) - π₯οΈ the System β API admin page β renders the checkbox matrix from it
- π the docs page β shows each endpoint's required permission
Add a resource here and it appears in the key-creation UI automatically. apiV1NormalisePermissions() validates any raw permission structure (from the admin UI or a stored JSON blob) against the catalog β unknown resources/actions are silently dropped, so a stale or hand-edited permission blob can never grant something that doesn't exist.
A key's permissions are stored on its row as JSON:
{ "tickets": ["read", "create"], "ticket_notes": ["read"] }Anything absent is denied. There is no "admin" or "all" flag to leak.
apiAuthenticate() runs on every request:
-
Extract the key β
Authorization: Bearer <key>preferred; a rawAuthorizationvalue orX-Api-Keyheader also accepted;$_SERVERfallbacks for FastCGI setups (see.htaccessabove). -
Look it up by hash. The DB stores
key_hash = SHA-256(key)β the lookup hashes the presented key and matches the unique index. The plaintext key exists nowhere on the server. -
Gate checks, in order, each with its own error code: unknown hash β
401 unauthenticatedΒ·active = 0β403 key_disabledΒ· pastexpires_atβ403 key_expired(the message includes when it expired) Β· the acts-as analyst was deactivated β403 key_disabled(deactivating an analyst kills their keys β an offboarding safety net). -
Rate limit (below), then stamp
last_used_at+last_used_ip(best-effort; a failure here never blocks the request). - Decode the permissions JSON and company scope onto the key row that gets handed to every handler.
Rate limiting β fixed one-minute windows in api_key_rate_limits:
INSERT INTO api_key_rate_limits (api_key_id, request_count, window_start)
VALUES (?, 1, ?)
ON DUPLICATE KEY UPDATE request_count = request_count + 1One atomic upsert per request against a UNIQUE(api_key_id, window_start) index β no race conditions, no locks. Windows older than five minutes are deleted opportunistically. The limit is the key's own override, else system_settings.api_rate_limit_per_minute, else 60/min. Every response (even errors) carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset so integrations can pace themselves; going over returns 429 rate_limited. If the rate-limit table itself errors, the check fails open β a broken bookkeeping table should never take the API down.
Company (tenant) scoping β the API-key mirror of includes/tenancy.php's analyst helpers:
| Helper | Mirrors | Used for |
|---|---|---|
apiKeyCanAccessTenant() |
analystCanAccessTenant() |
validating company_id inputs |
apiKeyCanAccessTicket() / apiKeyCanAccessProblem()
|
analystCanAccessTicket() / analystCanAccessProblem()
|
every by-id read/write (both thin wrappers over the generic apiKeyCanAccessTenantRow()) |
apiKeyTenantFilter() |
ticketTenantFilter() |
every list query on a tenant-scoped table (returns a SQL fragment + params) |
apiKeyDefaultTenantId() |
getActiveTenantId() |
which company a new ticket/problem lands in |
Same semantics as the UI: a ticket with tenant_id NULL belongs to the Default company; on a single-company install nothing is filtered at all (multi-tenancy stays invisible at N=1); a key scoped to specific companies simply cannot see, list, create in, or move tickets outside them.
Note
Company scoping applies to tickets and problems (and any future module with a tenant_id). Assets have no company column β they are install-wide, exactly as in the UI β so a key's company scope doesn't restrict /assets routes. Cross-module rules are enforced too: linking an incident to a problem requires both to belong to the same company.
Shared plumbing in tickets.php:
-
apiTicketSelect()β one canonicalSELECTwith all sevenLEFT JOINs (status, priority, type, origin, department, analyst, requester, company), so every handler serialises identical objects. -
apiSerializeTicket()β row β JSON shape. Related entities become nested objects ("status": {"id": 2, "name": "In Progress", "is_closed": false}) rather than bare foreign keys, so consumers never need a second lookup to display a ticket. Booleans are real booleans, ids real ints, dates ISO 8601 UTC. -
apiLoadTicket()β the by-id gate every ticket handler calls first: company-scope check + existence check, both failing as 404 (see Security below). -
apiActorId()β the analyst the key acts as; every write is attributed to it. -
apiAuditWrite()β appendsticket_auditrows. Status audit rows store the status name, because the SLA engine rebuilds its pause/resume timeline by parsing exactly those rows. -
Resolvers β
apiResolveStatus()/apiResolvePriority()accept name or id ("status": "On Hold"or"status_id": 3) and return[id, name, β¦];apiValidateLookupId()turns an unknown foreign key into a clean422instead of a SQL foreign-key explosion.
The write handlers deliberately mirror the UI endpoints line-for-line:
-
POST /ticketsmirrorsapi/tickets/create_ticket.php: find-or-create the requester by email, generate theXXX-###-#####ticket number, insert the ticket and its initialemailsrow (tickets have no body column β the request text is the initial message, which is why API tickets appear in the inbox like any other), write the "Ticket Created" audit row, commit the transaction, then dispatch theticket.createdworkflow event. -
PATCH /tickets/{id}mirrorsapi/tickets/assign_ticket.php: dynamicUPDATEof only the fields sent,closed_datetimeset on close / cleared on reopen,assigned_analyst_id+owner_idkept in sync, then β after the update is durable β assignment/closure template emails, the CSAT auto-trigger (when mode isauto), and the workflow dispatches (ticket.status_changed,ticket.priority_changed,ticket.assigned) with a canonical read-back payload. Each side effect is wrapped so a mail/workflow outage can never break the API response. -
DELETE/restoreare the trash semantics (deleted_datetimesoft delete), and time-entry deletion is the same soft delete (is_active = 0) the UI uses.
users.php (requesters) and reference.php (lookups) follow the same pattern; reference.php's ticket-types/origins lists route through getTenantConfigRows() when a company_id is passed, so per-company add/hide overrides are honoured.
| Layer | Mechanism |
|---|---|
| Key storage | SHA-256 hash only, unique-indexed. The full fitsm_β¦ key (6-char prefix + 48 hex chars β 192 bits from random_bytes) is displayed once at creation. A leaked database dump yields no usable keys. |
| Identification |
key_prefix (first 14 chars) is stored separately purely so the admin list can show which key is which without ever storing the secret. |
| Least privilege | Zero-permission start; per-resource Γ per-action grants; the missing permission is named in the 403 so integrators can fix their key instead of guessing. |
| Attribution | Every key acts as an analyst β ticket_audit, ticket_notes and ticket_time_entries have NOT NULL analyst FKs, so API writes carry a real, queryable author instead of an anonymous hole in the audit trail. |
| Company isolation | Enforced twice, like the UI: a SQL filter on every list query and a by-id gate on every detail/write β plus the dual-side check when moving a ticket between companies. |
| 404, not 403 | A ticket outside the key's company scope returns 404 Not Found β the API never confirms that a foreign company's ticket id exists. |
| Injection | Prepared statements everywhere; sort fields and filter columns come from whitelist maps, never from user input interpolated into SQL. |
| Input validation | Unknown status/priority/type/origin/department/analyst ids β 422 with the field named; emails via filter_var; dates via DateTimeImmutable parsing. |
| Error hygiene |
display_errors off + global exception handler β internals go to the error log, clients get generic server_error. |
| Lifecycle controls | Per-key expiry, instant disable/delete, deactivated-analyst kill switch, last_used_at/last_used_ip for spotting stale or suspicious keys. |
| Transport | The key travels in a header β use HTTPS in production (the docs and help page both say so out loud). |
Reading input. Query parameters filter and shape lists; the JSON body carries create/update fields. apiJsonBody() guarantees handlers always receive an array. Three input conveniences run through the whole surface:
-
Name or id β anywhere a lookup is set you can send the human name (
"priority": "High") or the id ("priority_id": 3). -
Explicit null clears β on
PATCH, omitting a field leaves it alone; sending"priority_id": null(or"") clears it. The distinction isarray_key_existsvs value checks. -
Change detection β
PATCHcompares each sent field against the current row; only genuine changes hit theUPDATE, the audit log, the emails and the workflow engine. Re-sending the same PATCH is idempotent: no duplicate audit rows, no repeat notifications, same response.
Building the response. Handlers never echo β they hand plain PHP arrays to apiRespond(). Serialisers own the shape: nested related objects, typed scalars, ISO dates, and after any write the handler re-loads and re-serialises the row, so the response always reflects what's actually in the database (including side effects like closed_at).
Pagination. Lists run two queries β a COUNT(*) with identical WHERE/scope, then the page (LIMIT/OFFSET) β and return:
{
"data": [ ...25 tickets... ],
"meta": { "page": 1, "per_page": 25, "total": 137, "total_pages": 6 }
}Sorting. ?sort=-created_at β leading - for descending. The sort key is looked up in a whitelist map ('created_at' => 't.created_datetime', β¦); an unknown key is a 400 that lists the valid options.
| HTTP | error.code |
When |
|---|---|---|
| 400 | invalid_json |
Body present but not valid JSON |
| 400 | invalid_parameter |
Bad date format, unknown sort field |
| 401 | unauthenticated |
Missing or unknown key |
| 403 |
key_disabled / key_expired
|
Key disabled, expired, or its analyst deactivated |
| 403 | forbidden |
Missing resource.action permission, or a company outside the key's scope |
| 404 | not_found |
Unknown route, or a resource the key isn't allowed to know exists |
| 405 | method_not_allowed |
Right path, wrong verb (response carries Allow:) |
| 409 | conflict |
e.g. updating a trashed ticket, duplicate requester email |
| 422 |
missing_field / invalid_field
|
Validation failures β the message names the field |
| 429 | rate_limited |
Over the per-key per-minute limit |
| 500 | server_error |
Anything unexpected (details in the server error log only) |
Two tables (in database/freeitsm.sql, auto-created by System β Database Verification via db_verify.php's $schema + unique-index + FK sections):
api_keys β name, key_prefix, key_hash (unique), analyst_id (FK, the acts-as identity), permissions (JSON), company_ids (JSON list or NULL = all), rate_limit_per_minute (NULL = system default), active, expires_at, last_used_at, last_used_ip, created_by (FK), created_datetime.
api_key_rate_limits β api_key_id (FK, ON DELETE CASCADE β delete a key and its counters vanish), request_count, window_start, unique per key+window.
The admin endpoints behind the System β API page are ordinary session-authenticated internal endpoints (api/system/api_keys/list_keys.php, create_key.php, update_key.php, delete_key.php).
The rollout plan is one module at a time (tickets β , assets β , problems β , changes β , knowledge β , tasks β , cmdb β , contracts β , calendar β , software β , service-status β , then morning-checks, formsβ¦). Adding a module touches four places:
-
lib/permissions.phpβ add the module's resources + actions to the catalog (they appear in the key-creation matrix automatically). -
resources/<module>.phpβ the handlers, reusing the serialiser/loader/audit patterns fromtickets.phpand mirroring the module's own UI endpoints for write parity. -
index.phpβ routes in the table, each declaring its permission. -
system/api/docs.phpβ entries in theSPECarray so the interactive docs + tester stay complete. (And a newREST-API-<Module>wiki page.)
No changes to auth, rate limiting, scoping, error handling or the admin UI are needed β that's all inherited.
See also: REST API: Tickets Β· REST API: Assets Β· REST API: Problems Β· REST API: Changes Β· REST API: Knowledge Β· REST API: Tasks Β· REST API: CMDB Β· REST API: Contracts Β· REST API: Calendar Β· REST API: Software Β· REST API: Service Status Β· REST API: Morning Checks Β· REST API: Forms Β· REST API: Workflow Β· REST API: Network Mapper (the endpoint usage guides) Β· API Reference (the internal session-based AJAX endpoints) Β· Security Β· Multi-Tenancy Β· Database Integrity.
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)