Skip to content

REST API

Ed Mozley edited this page Jul 2, 2026 · 17 revisions

πŸ”Œ REST API β€” How It Works

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 β€” REST API: Tickets and REST API: Assets so far. The interactive version (with a live "Try it" tester) is built into the product at System β†’ API β†’ Documentation.

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.

πŸ—ΊοΈ The request lifecycle

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"]
Loading

Every request passes through the same five gates β€” routing β†’ authentication β†’ rate limit β†’ permission β†’ tenancy β€” before a single line of business logic runs.


πŸ“ File-by-file tour

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
    β”œβ”€β”€ users.php          ← requesters (end users)
    └── reference.php      ← statuses, priorities, types, origins, departments, analysts, companies

.htaccess β€” clean URLs without breaking anyone

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

  1. Clean URLs. /api/v1/tickets/42 is silently rewritten to /api/v1/index.php/tickets/42. QSA keeps the query string, and the two RewriteCond lines make sure real files (like index.php itself) are never rewritten.
  2. 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/42 because the router reads PATH_INFO either way. Nothing else in FreeITSM uses mod_rewrite, so the API couldn't assume it exists.
  3. Authorization header rescue. Some Apache/FastCGI configurations strip the Authorization header before PHP sees it. SetEnvIf re-injects it as HTTP_AUTHORIZATION, and apiExtractKey() checks $_SERVER['HTTP_AUTHORIZATION'] / REDIRECT_HTTP_AUTHORIZATION as 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.

index.php β€” the front controller

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?"

lib/bootstrap.php β€” boot & last-resort safety net

  • Loads config.php (which pulls DB credentials from outside the web root), includes/functions.php (connectToDatabase() β€” PDO with exceptions), and includes/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 an X-FreeITSM-Api-Version: 1 fingerprint.
  • Answers CORS preflight (OPTIONS) with 204 before authentication β€” browsers don't send credentials on preflight, so it must succeed keyless.
  • ini_set('display_errors', '0') + a global set_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 clean 500 {"error":{"code":"server_error",...}}.

lib/response.php β€” one envelope, everywhere

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.

lib/permissions.php β€” the single source of truth

apiV1PermissionCatalog() returns every resource, action and human description the API knows about. Three consumers read this one function:

  1. πŸ”’ the auth layer β€” enforcement (apiRequirePermission)
  2. πŸ–₯️ the System β†’ API admin page β€” renders the checkbox matrix from it
  3. πŸ“– 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.

lib/auth.php β€” keys, rate limits, company scoping

apiAuthenticate() runs on every request:

  1. Extract the key β€” Authorization: Bearer <key> preferred; a raw Authorization value or X-Api-Key header also accepted; $_SERVER fallbacks for FastCGI setups (see .htaccess above).
  2. 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.
  3. Gate checks, in order, each with its own error code: unknown hash β†’ 401 unauthenticated Β· active = 0 β†’ 403 key_disabled Β· past expires_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).
  4. Rate limit (below), then stamp last_used_at + last_used_ip (best-effort; a failure here never blocks the request).
  5. 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 + 1

One 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() analystCanAccessTicket() every by-id read/write
apiKeyTicketFilter() ticketTenantFilter() every list query (returns a SQL fragment + params)
apiKeyDefaultTenantId() getActiveTenantId() which company a new ticket 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 anything else 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.

resources/*.php β€” the handlers

Shared plumbing in tickets.php:

  • apiTicketSelect() β€” one canonical SELECT with all seven LEFT 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() β€” appends ticket_audit rows. 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 clean 422 instead of a SQL foreign-key explosion.

The write handlers deliberately mirror the UI endpoints line-for-line:

  • POST /tickets mirrors api/tickets/create_ticket.php: find-or-create the requester by email, generate the XXX-###-##### ticket number, insert the ticket and its initial emails row (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 the ticket.created workflow event.
  • PATCH /tickets/{id} mirrors api/tickets/assign_ticket.php: dynamic UPDATE of only the fields sent, closed_datetime set on close / cleared on reopen, assigned_analyst_id + owner_id kept in sync, then β€” after the update is durable β€” assignment/closure template emails, the CSAT auto-trigger (when mode is auto), 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 / restore are the trash semantics (deleted_datetime soft 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.


πŸ›‘οΈ Security model

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

🧾 Parameters, JSON building & pagination

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 is array_key_exists vs value checks.
  • Change detection β€” PATCH compares each sent field against the current row; only genuine changes hit the UPDATE, 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.


🚨 Error handling reference

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)

πŸ—„οΈ Database

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


🧩 Extending the API (the per-module recipe)

The rollout plan is one module at a time (tickets βœ…, assets βœ…, then problems, changes…). Adding a module touches four places:

  1. lib/permissions.php β€” add the module's resources + actions to the catalog (they appear in the key-creation matrix automatically).
  2. resources/<module>.php β€” the handlers, reusing the serialiser/loader/audit patterns from tickets.php and mirroring the module's own UI endpoints for write parity.
  3. index.php β€” routes in the table, each declaring its permission.
  4. system/api/docs.php β€” entries in the SPEC array so the interactive docs + tester stay complete. (And a new REST-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 (the endpoint usage guides) Β· API Reference (the internal session-based AJAX endpoints) Β· Security Β· Multi-Tenancy.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally