SafeScan (HSE)
Model health-and-safety incidents, near-misses, unsafe acts, and observations across projects and shipments. Advance events through a structured lifecycle, issue stop-work orders, attach root-cause analysis, broadcast safety alerts, and sync offline field data — all through a single tenant client.
Concepts
SafeScan organises HSE work around the following resources:
- Projects — A construction, logistics, or industrial project site. Shipments belong to projects.
- Shipments — A transport leg or cargo movement linked to a project, carrying a transport mode (road, rail, sea, barge, air, port, yard).
- Events — The core record: an incident, near-miss, unsafe act, or general safety observation. Events carry severity, location, and a status that advances through a three-stage lifecycle.
- Actions — Corrective or preventive tasks assigned to a user, each with a due date and priority.
- RCA — Root-cause analysis attached to an event, modelled as a tree of why-nodes (5-Whys or similar).
- Alerts — Safety broadcast messages derived from an event, published to a filtered audience.
- Lessons — Lessons-learned entries that can be linked to an alert or reference an external source.
- Media — Photos, videos, or audio clips attached to an event via a signed-upload flow.
- Sync — Offline-first delta sync so field devices can push queued operations and pull server changes since a known sequence number.
Project ──┬── Shipment
└── Event ──┬── Action (corrective task)
├── RCA (why-tree)
├── Alert → Lesson
└── Media (photo / video / audio)
Event lifecycle
Events move forward through three statuses. Use hseEvents.transition to advance them.
| Status | Description |
|---|---|
open | Event reported; investigation not yet started |
under_investigation | Root-cause analysis or actions in progress |
closed | All actions resolved; event signed off |
A stopWork flag is orthogonal to status — it can be set or cleared at any lifecycle stage via hseEvents.stopWork.
Endpoints
/v1/tenants/{tenantId}/hse/projectsCreate a project/v1/tenants/{tenantId}/hse/projectsList projects (paginated)/v1/tenants/{tenantId}/hse/projects/{id}Get a single project/v1/tenants/{tenantId}/hse/projects/{id}Update project name, region, or status/v1/tenants/{tenantId}/hse/shipmentsCreate a shipment/v1/tenants/{tenantId}/hse/projects/{id}/shipmentsList shipments for a project/v1/tenants/{tenantId}/hse/eventsUpsert an event (create or idempotent update)/v1/tenants/{tenantId}/hse/eventsList events with filters and pagination/v1/tenants/{tenantId}/hse/events/{id}Get event detail (includes actions, media, RCA, alerts)/v1/tenants/{tenantId}/hse/events/{id}/transitionAdvance event status/v1/tenants/{tenantId}/hse/events/{id}/stopworkSet or clear the stop-work flag/v1/tenants/{tenantId}/hse/events/{eventId}/actionsCreate a corrective action/v1/tenants/{tenantId}/hse/events/{eventId}/actionsList actions for an event/v1/tenants/{tenantId}/hse/actions/{id}Get a single action/v1/tenants/{tenantId}/hse/actions/{id}Update action status, due date, or assignee/v1/tenants/{tenantId}/hse/events/{eventId}/rcaUpsert root-cause analysis/v1/tenants/{tenantId}/hse/events/{eventId}/rcaGet RCA for an event/v1/tenants/{tenantId}/hse/events/{eventId}/alertCreate a safety alert from an event/v1/tenants/{tenantId}/hse/alerts/{alertId}/publishPublish an alert to its audience/v1/tenants/{tenantId}/hse/alertsList safety alerts/v1/tenants/{tenantId}/hse/lessonsCreate a lesson learned/v1/tenants/{tenantId}/hse/lessonsList lessons learned/v1/tenants/{tenantId}/hse/media/signed-urlGet a signed upload URL for event media/v1/tenants/{tenantId}/hse/media/{mediaId}/completeMark a media upload complete/v1/tenants/{tenantId}/hse/sync/pullPull server changes since a sequence cursor/v1/tenants/{tenantId}/hse/sync/pushPush offline operations to the serverProjects and shipments
Create a project
const project = await tenant.hseProjects.create({
code: 'PRJ-2026-BXL',
name: 'Brussels Terminal Expansion',
region: 'EMEA',
businessUnit: 'Logistics',
});
console.log(project.id); // "hpr_abc123"
console.log(project.status); // "active"Register a shipment
const shipment = await tenant.hseProjects.createShipment({
projectId: project.id,
code: 'SHP-BXL-001',
name: 'Antwerp → Brussels — Road Leg 1',
});
// List all shipments for the project
const shipments = await tenant.hseProjects.listShipments(project.id);Reporting an event
Use hseEvents.upsert to create or idempotently re-submit an event. Pass a stable clientId (a UUID your app controls) to prevent duplicate reports from offline retries.
const event = await tenant.hseEvents.upsert({
clientId: 'a3f1c2e4-0001-4d2a-b9f3-112233445566',
kind: 'incident',
severity: 'high',
description: 'Forklift struck rack in aisle 7, two pallets fell.',
occurredAt: '2026-07-23T08:15:00Z',
latitude: 51.2194,
longitude: 4.4025,
projectId: project.id,
shipmentId: shipment.id,
transportMode: 'yard',
stopWork: true,
});
console.log(event.id); // "hev_xyz789"
console.log(event.status); // "open"
console.log(event.stopWork); // trueEvent kinds and severity
kind | Use when |
|---|---|
incident | An injury, property damage, or environmental release occurred |
near_miss | A hazardous situation with no harm but significant potential |
unsafe_act | A person behaved unsafely but no incident followed |
observation | A general safety observation or positive recognition |
severity is a free-text string; typical values are low, medium, high, and critical.
Listing and filtering events
// Filter by status and project
const page = await tenant.hseEvents.list({
status: 'open',
projectId: project.id,
limit: 50,
});
console.log(page.data); // HseEvent[]
console.log(page.pagination.hasMore); // true | false
// Date-range filter
const weekEvents = await tenant.hseEvents.list({
dateFrom: '2026-07-01T00:00:00Z',
dateTo: '2026-07-31T23:59:59Z',
kind: 'incident',
severity: 'high',
});Paginating all events
hseEvents.listAll returns an async generator that follows the cursor automatically. See the Pagination guide for the full pattern.
// Stream every open event in the project — no manual cursor handling
for await (const e of tenant.hseEvents.listAll({
status: 'open',
projectId: project.id,
})) {
console.log(e.id, e.kind, e.severity);
}Advancing event status
// Open → under investigation
await tenant.hseEvents.transition(event.id, {
to: 'under_investigation',
assigneeId: 'usr_safety_officer_01',
reason: 'Root-cause analysis started',
});
// Under investigation → closed
await tenant.hseEvents.transition(event.id, {
to: 'closed',
reason: 'All corrective actions verified, area cleared.',
});Stop-work orders
Set or clear the stop-work flag independently of the event status.
// Issue a stop-work order
await tenant.hseEvents.stopWork(event.id, {
stopWork: true,
reason: 'Area unsafe — rack integrity unverified.',
});
// Clear once the area is safe
await tenant.hseEvents.stopWork(event.id, {
stopWork: false,
reason: 'Structural inspection passed, work resumed.',
});Corrective actions
// Create an action
const action = await tenant.hseActions.create(event.id, {
assigneeId: 'usr_maintenance_02',
description: 'Inspect all racking in aisles 6–8 and replace damaged uprights.',
dueDate: '2026-07-30T17:00:00Z',
priority: 'high',
});
console.log(action.id); // "hac_aaa111"
console.log(action.status); // "open"
// Update action — mark in progress
await tenant.hseActions.update(action.id, {
status: 'in_progress',
});
// Close the action when remediation is done
await tenant.hseActions.update(action.id, {
status: 'closed',
dueDate: '2026-07-28T14:00:00Z',
});
// List all actions for the event
const actionsPage = await tenant.hseActions.list(event.id, { limit: 20 });Action statuses
| Status | Description |
|---|---|
open | Task created, not yet started |
in_progress | Assignee is working on the remediation |
closed | Remediation completed and verified |
overdue | Due date passed with no closure |
Root-cause analysis
The RCA is a tree of why-nodes modelled after the 5-Whys method. Each node has a question, an answer, an optional factorType, and a parentId that builds the tree. Use hseRca.upsert to create or replace the analysis in one call.
await tenant.hseRca.upsert(event.id, {
method: '5-whys',
summary: 'Root cause: inadequate aisle width markings led to forklift path conflict.',
nodes: [
{
id: 'node_1',
question: 'Why did the forklift strike the rack?',
answer: 'The aisle width markings were worn and invisible.',
factorType: 'environment',
sortOrder: 1,
},
{
id: 'node_2',
parentId: 'node_1',
question: 'Why were the markings worn?',
answer: 'Floor repainting was overdue by 3 months.',
factorType: 'management',
sortOrder: 2,
},
{
id: 'node_3',
parentId: 'node_2',
question: 'Why was repainting overdue?',
answer: 'No scheduled maintenance reminder was in place.',
factorType: 'process',
sortOrder: 3,
},
],
});
// Read back the RCA
const rca = await tenant.hseRca.get(event.id);
console.log(rca.method); // "5-whys"
console.log(rca.nodes.length); // 3RCA factor types
factorType | Represents |
|---|---|
people | Human behaviour, training, or fatigue |
process | Procedures, work instructions, scheduling |
equipment | Tools, machinery, PPE |
environment | Physical workspace, weather, signage |
management | Oversight, resource allocation, culture |
Safety alerts and lessons learned
Once an incident is understood, publish a safety alert to inform the wider organisation.
// Create an alert from the event
const alert = await tenant.hseAlerts.createFromEvent(event.id, {
title: 'Forklift aisle collision — floor marking hazard',
body: 'An incident occurred in aisle 7 due to worn floor markings. All sites must audit aisle markings this week.',
audienceFilter: {
regions: ['EMEA'],
businessUnits: ['Logistics'],
},
});
// Publish to the filtered audience
await tenant.hseAlerts.publish(alert.id);
// Record a lesson learned
await tenant.hseAlerts.createLesson({
alertId: alert.id,
category: 'floor-safety',
summary: 'Worn aisle markings are a leading indicator of forklift collision risk. Add quarterly floor audit to site checklist.',
tags: ['forklift', 'floor-marking', 'aisle-safety'],
});Attaching media
Field workers capture photo or video evidence on their device. The upload flow is: request a signed URL, PUT the file directly to cloud storage, then call completeUpload to commit the record.
// 1. Request a signed upload URL
const { uploadUrl, storageRef, expiresAt } = await tenant.hseMedia.getSignedUrl({
eventId: event.id,
mediaId: 'med_local_uuid_001',
mediaKind: 'photo',
contentType: 'image/jpeg',
fileName: 'aisle7-damage.jpg',
});
// 2. PUT the file directly to cloud storage
await fetch(uploadUrl, {
method: 'PUT',
body: imageBuffer,
headers: { 'Content-Type': 'image/jpeg' },
});
// 3. Commit the upload
const media = await tenant.hseMedia.completeUpload('med_local_uuid_001', {
storageRef,
fileSize: imageBuffer.byteLength,
});
console.log(media.uploadState); // "uploaded"Offline sync
SafeScan is designed for field use where connectivity is unreliable. Devices queue operations locally and sync when back online.
Pull (server → device)
// First pull: since=0 returns everything
const pullResult = await tenant.hseSync.pull({
since: 0,
clientDeviceId: 'device_field_unit_07',
limit: 200,
});
console.log(pullResult.events); // HseEventDetail[] — full event graphs
console.log(pullResult.cursor); // e.g. 1048576 — new sequence number
console.log(pullResult.hasMore); // true if more pages to pull
// Subsequent pull: pass the last cursor
const delta = await tenant.hseSync.pull({
since: pullResult.cursor,
clientDeviceId: 'device_field_unit_07',
});Push (device → server)
const pushResult = await tenant.hseSync.push({
clientDeviceId: 'device_field_unit_07',
ops: [
{
opId: 'op_uuid_001',
entity: 'event',
id: 'a3f1c2e4-0001-4d2a-b9f3-112233445566',
type: 'upsert',
seq: 1,
payload: {
clientId: 'a3f1c2e4-0001-4d2a-b9f3-112233445566',
kind: 'near_miss',
severity: 'medium',
description: 'Near-miss at loading bay 3.',
occurredAt: '2026-07-23T10:00:00Z',
},
},
],
});
for (const result of pushResult.results) {
console.log(result.opId, result.status);
// e.g. "op_uuid_001" "applied"
}Push result statuses
status | Meaning |
|---|---|
applied | Operation accepted and persisted |
duplicate | opId already seen; server returned the existing record |
conflict | Server state conflicts with the operation payload |
rejected | Operation was invalid (see error for details) |
End-to-end example
Report an incident in the field, investigate, attach evidence, and close.
// 1. Report the incident (offline-safe via clientId)
const event = await tenant.hseEvents.upsert({
clientId: 'c0ffee00-beef-4a1b-8bad-000000000001',
kind: 'incident',
severity: 'high',
description: 'Chemical spill in bay 4 — drum punctured during unloading.',
occurredAt: new Date().toISOString(),
projectId: 'hpr_terminal_01',
transportMode: 'road',
stopWork: true,
});
// 2. Issue the stop-work order explicitly
await tenant.hseEvents.stopWork(event.id, {
stopWork: true,
reason: 'Hazmat spill — bay 4 cordoned off.',
});
// 3. Start investigation
await tenant.hseEvents.transition(event.id, {
to: 'under_investigation',
assigneeId: 'usr_ehs_lead',
reason: 'EHS team on site.',
});
// 4. Create corrective actions
await tenant.hseActions.create(event.id, {
assigneeId: 'usr_hazmat_team',
description: 'Contain and neutralise spill; dispose of contaminated materials.',
dueDate: new Date(Date.now() + 4 * 60 * 60 * 1000).toISOString(),
priority: 'critical',
});
// 5. RCA
await tenant.hseRca.upsert(event.id, {
method: '5-whys',
summary: 'Drum was over-pressurised during road transport; valve failed on impact.',
nodes: [
{
id: 'n1',
question: 'Why did the drum puncture?',
answer: 'Internal pressure exceeded rated limit.',
factorType: 'equipment',
sortOrder: 1,
},
{
id: 'n2',
parentId: 'n1',
question: 'Why was pressure excessive?',
answer: 'Temperature relief valve was not inspected before shipment.',
factorType: 'process',
sortOrder: 2,
},
],
});
// 6. Alert the organisation
const alert = await tenant.hseAlerts.createFromEvent(event.id, {
title: 'Chemical drum spill — Bay 4 — Antwerp terminal',
body: 'Drum over-pressurisation caused a spill. All sites must verify valve inspections before loading.',
audienceFilter: { businessUnits: ['Logistics', 'QHSE'] },
});
await tenant.hseAlerts.publish(alert.id);
// 7. Iterate all open high-severity events to check scope
for await (const e of tenant.hseEvents.listAll({ status: 'open', severity: 'high' })) {
console.log(e.id, e.kind, e.occurredAt);
}
// 8. Close after remediation
await tenant.hseEvents.transition(event.id, {
to: 'closed',
reason: 'Spill contained, bay cleared, corrective actions verified.',
});
await tenant.hseEvents.stopWork(event.id, { stopWork: false });Next steps
- Pagination — How
listAllasync generators work under the hood - Observations — Capture photo evidence linked to a location
- Webhooks — React to HSE event lifecycle changes in real time
- Missions & Routes — Schedule field inspection visits