# API reference JSON over HTTP under `/api`. Authentication uses a session cookie (`safetylog_session`, http-only) set by setup, sign-in, emailed links and password changes. Request bodies are validated against [`shared/schemas.ts`](../shared/schemas.ts); response shapes are in [`shared/api-types.ts`](../shared/api-types.ts). Errors look like `{ "error": "message" }`; validation errors (400) also include `issues: [{ path, message }]`. Roles, from least to most privileged: `reporter` < `supervisor` < `safety_manager` < `admin`. A role can do everything the roles below it can. Browsers must send an `Origin` header matching the site on form/multipart and body-less requests (all browsers do); JSON requests are protected by CORS. ## Setup and authentication | Method | Path | Who | Notes | | --- | --- | --- | --- | | GET | `/api/health` | anyone | `{ ok: true }` | | GET | `/api/setup` | anyone | `{ needsSetup }`: true until the first admin exists | | POST | `/api/setup` | anyone, once | `{ organizationName, name, email, password }`. Creates the organization and an admin, signs them in. 409 afterwards | | POST | `/api/auth/login` | anyone | `{ email, password }`. 401 on bad credentials, 429 after 10 attempts per email in 15 minutes | | POST | `/api/auth/magic-link` | anyone | `{ email }`. Emails a single-use sign-in link (15 minutes). Always 204 | | POST | `/api/auth/magic-link/verify` | anyone | `{ token }`. Signs in | | POST | `/api/auth/password-reset` | anyone | `{ email }`. Emails a reset link (1 hour). Always 204 | | POST | `/api/auth/password-reset/confirm` | anyone | `{ token, newPassword }`. Sets the password, signs out other sessions, signs in. Also completes invitations | | POST | `/api/auth/logout` | anyone | Ends the session. 204 | | GET | `/api/auth/me` | signed in | `{ user, organization }` | | POST | `/api/auth/password` | signed in | `{ currentPassword, newPassword }`. Signs out all other sessions. 204 | Passwords need at least 10 characters. Email requests are limited to 5 per address per 15 minutes. Emailed links land on web pages (`/auth/magic`, `/auth/reset`) that POST the token, so link scanners in email systems can't use them up. ## Users | Method | Path | Who | Notes | | --- | --- | --- | --- | | GET | `/api/users` | safety_manager | All users in the organization | | GET | `/api/users/assignable` | supervisor | Active users `{ id, name, role }`, for assigning leads and actions | | POST | `/api/users` | admin | `{ name, email, role, password? }`. Without `password` an invitation email is sent (link valid 7 days). Returns `{ user, invited }` | | PATCH | `/api/users/:id` | admin | `{ name?, role?, active?, password? }`. Deactivating or setting a password signs the user out. The last active admin can't be demoted or deactivated | ## Sites and areas | Method | Path | Who | Notes | | --- | --- | --- | --- | | GET | `/api/sites` | signed in | Sites with their `areas` (flat list; build the tree from `parentId`). `?includeArchived=true` to include archived | | POST | `/api/sites` | safety_manager | `{ name, address?, timezone? }` | | PATCH | `/api/sites/:id` | safety_manager | `{ name?, address?, timezone?, archived? }` | | POST | `/api/sites/:id/areas` | safety_manager | `{ name, kind?, parentId? }`. `kind`: `building`, `area` (default), `line`, `other` | | PATCH | `/api/areas/:id` | safety_manager | `{ name?, kind?, archived? }` | ## Incidents Addressed by number (`/api/incidents/42` is `INC-0042`). Numbers are sequential per organization with no gaps. **Visibility:** supervisors and above see every incident. Reporters see incidents they reported plus any where they're assigned a corrective action. Anything else answers 404. | Method | Path | Who | Notes | | --- | --- | --- | --- | | GET | `/api/incidents` | signed in | `{ items, page, pageSize, total }`, newest first | | GET | `/api/incidents/export` | signed in | CSV of the same filters (no paging, up to 10,000 rows) | | POST | `/api/incidents` | signed in | Create a report (below). High/critical reports email safety managers and admins | | GET | `/api/incidents/:number` | signed in | Full detail: investigation, root causes, actions, attachments, the `transitions` you may perform (with `blockedReason` when not yet possible) and `can` flags | | PATCH | `/api/incidents/:number` | supervisor | Any create field except `anonymous`. Status changes use transitions | | POST | `/api/incidents/:number/transitions` | see below | `{ to, note? }`. The note is also posted as a comment | | GET/POST | `/api/incidents/:number/comments` | can see it | POST `{ body }` | | GET | `/api/incidents/:number/timeline` | can see it | Audit entries `{ id, at, actor, action, changes }` | | PUT | `/api/incidents/:number/investigation` | supervisor | `{ leadId?, method?, summary?, completed? }`. Creates the investigation if needed | | POST | `/api/incidents/:number/root-causes` | supervisor | `{ category, description, whys? }` | | POST | `/api/incidents/:number/actions` | supervisor | `{ title, description?, kind?, assigneeId?, dueDate?, rootCauseId? }`. Not on closed incidents. Emails the assignee | | POST | `/api/incidents/:number/attachments` | can see it | Multipart, field `file`. Photos (JPEG, PNG, WebP, GIF, HEIC) and PDFs only, detected from content; `MAX_UPLOAD_MB` each (default 10), 20 per incident | **List and export filters:** `page`, `pageSize` (1–100, default 25), `q` (title, description, location; `INC-0042` or `42` matches the number), `type`, `severity`, `status`, `siteId`. Send a random `clientId` (UUID) with each report. Resubmitting the same `clientId` returns the original report (200 instead of 201) instead of creating a duplicate, so devices can safely retry after a dropped connection. **Create body:** ```json { "title": "Forklift reversed into pallet stack", "type": "near_miss", "severity": "high", "occurredAt": "2026-10-06T08:15:00-05:00", "siteId": "…", "areaId": "…", "locationDetail": "Loading bay C", "description": "…", "immediateAction": "Area cordoned off", "anonymous": false, "clientId": "6f1c…" } ``` - `type`: `near_miss`, `hazard`, `injury`, `illness`, `property_damage`, `environmental` - `severity`: `low`, `medium`, `high`, `critical` - `occurredAt` needs a time-zone offset and can't be in the future. Anonymous reports don't record the reporter at all. ### Workflow `reported` → `triage` → `investigation` → `actions` → `verification` → `closed`. The rules live in [`shared/workflow.ts`](../shared/workflow.ts) and are enforced by the API. | From | To | Who | Condition | | --- | --- | --- | --- | | reported | triage | supervisor | | | triage | investigation | supervisor | | | triage | actions | supervisor | | | triage | closed | supervisor | Low or medium severity only | | investigation | actions | supervisor | At least one root cause | | actions | investigation | supervisor | | | actions | verification | supervisor | At least one action, none open or in progress | | verification | actions | safety_manager | | | verification | closed | safety_manager | Every (non-cancelled) action verified | | closed | investigation | safety_manager | Reopen | ## Root causes, actions, attachments | Method | Path | Who | Notes | | --- | --- | --- | --- | | PATCH | `/api/root-causes/:id` | supervisor | `{ category?, description?, whys? }` | | DELETE | `/api/root-causes/:id` | supervisor | | | GET | `/api/actions` | signed in | `?scope=mine\|all` (`all` needs supervisor) and `?filter=open\|overdue\|done\|all`. Sorted by due date | | PATCH | `/api/actions/:id` | see notes | Supervisors: any field and status except `verified`. Safety managers: also `verified`. Assignees: only `status` among `open`, `in_progress`, `done` | | GET | `/api/attachments/:id` | can see the incident | The file, with a sandboxing Content-Security-Policy | | DELETE | `/api/attachments/:id` | uploader or supervisor | | Each action has `overdue: true` when it's open or in progress and its due date has passed (server time zone, `TZ`). Assignees get one email digest per day listing their actions due tomorrow or overdue. ## Reporting links (kiosks and QR codes) Reporting links let people file reports without an account. Each has a token that can **only** create reports. | Method | Path | Who | Notes | | --- | --- | --- | --- | | GET | `/api/kiosks` | safety_manager | All links, including their tokens (to reprint QR codes) | | POST | `/api/kiosks` | safety_manager | `{ name, siteId? }`. `siteId` restricts the link to one site | | PATCH | `/api/kiosks/:id` | safety_manager | `{ name?, siteId?, revoked? }` | | GET | `/api/kiosk/config` | link token | Organization name, link name, and the sites/areas it may report on | | POST | `/api/kiosk/reports` | link token | Multipart: `report` (JSON: the create body minus `anonymous`, plus optional `reporterName`) and up to 5 `files`. Returns `{ reference, failedFiles }`. 30 reports per link per 10 minutes | The token goes in the `X-Kiosk-Token` header. Browsers can't send custom headers cross-site without a CORS preflight, which the API doesn't allow, so other websites can't submit through a link. Reports without `reporterName` are anonymous. Incidents filed this way have `channel: "kiosk"` and `kiosk: { id, name }`. The web app opens links at `/k/`, optionally with `?site=&area=` to pre-fill the location (as QR codes do). ## Stats | Method | Path | Who | Notes | | --- | --- | --- | --- | | GET | `/api/stats/overview` | signed in | Open incidents, actions due/overdue, average close time, status counts, weekly trend (12 weeks), incidents by site this year | | GET | `/api/stats/analytics` | signed in | `?days=` (7–730, default 90): counts by type and severity, top root-cause category, on-time action rate, monthly completions | Stats only count what the signed-in user can see. Every create and update on an incident, its investigation, actions and files is recorded in the audit log with who made it and what changed.