# SafeOnuba API > Receive SafeOnuba alerts and evacuations in your control room, your SCADA, your PSIM or your intranet, and acknowledge or close alerts from there. Page: https://safeonuba.com/en/developers · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. > **v1 · preview** > > The contract is awaiting sign-off from the first customer and may change. Any change will be noted in the [changelog](https://safeonuba.com/en/developers/changelog). The SafeOnuba API takes your plant’s alerts and evacuations to the systems your control room already uses: a SCADA, a PSIM or the intranet. It is meant so that the operator in your control room sees the same as the SafeOnuba dashboard, on their own screen, and can respond from there. ## What you can do - **Receive in real time** every alert and every change to an evacuation with [webhooks](https://safeonuba.com/en/developers/webhooks): a signed `POST` to your server for each event. - **Query** alerts, evacuations and their headcount, who is missing, workers, watches, sites, zones and the positions the plant’s privacy level lets you see. - **Acknowledge and close alerts** from your control room with `PUT /v1/alerts/{id}`. Always on behalf of an operator, who is recorded in the alert’s timeline. - **Catch up on what you missed** if your receiver goes down: `GET /v1/events` returns the events from the last 30 days, byte for byte the same as the ones that were sent. - **Integrate without watches** in the [sandbox](https://safeonuba.com/en/developers/sandbox), with simulated SOS, fall, heat stress and full evacuation scenarios. ## What it doesn’t do - **In v1 it doesn’t start evacuations.** The system proposes and a person decides in the SafeOnuba dashboard. Your control room receives the evacuation as soon as it starts and follows it until it ends. - **It doesn’t give vital signs**, neither individual nor aggregated. A heat stress alert says what the worker has been told to do, not their heart rate. - **It sees no more than the dashboard.** The API sees the same as a dashboard user with those permissions would. [Privacy and security](https://safeonuba.com/en/developers/privacy-and-security) explains it. ## Environments | Environment | Address | Status | | --- | --- | --- | | Production | `https://api.safeonuba.com` | Activated when the customer is registered. Not yet open to the public. | | Test (sandbox) | `https://sandbox.api.safeonuba.com` | Simulated watches. This is where you integrate. | Both have the same paths and the same contract. The only things that change when you move to production are the address and the credentials. ## Conventions - All paths are under `/v1`, and every event carries `api_version: "2026-09-24"`. More in [Versioning](https://safeonuba.com/en/developers/versioning). - Bodies are JSON, except the token request, which is `application/x-www-form-urlencoded`. - Identifiers are UUIDs. Dates are ISO 8601 in UTC. - Errors arrive as `application/problem+json` with the real HTTP status. See [Errors](https://safeonuba.com/en/developers/errors). - Lists are paginated with a cursor. See [Pagination](https://safeonuba.com/en/developers/pagination). - Two paths are public and need no token: `GET /v1/health` and `GET /v1/openapi.json`, the contract. > **Call the API from your server** > > The API has no CORS: a browser can’t call it directly. Requests go out from your backend, which is also where the `client_secret` should live. That is why this documentation has no “try it” button: the examples are code to copy. ## How it fits into your control room The usual route has three parts: 1. **A webhook receiver** on your network, with a public https URL. It verifies the signature, responds `2xx` straight away and puts the event on a queue. 2. **Your control room system** consumes that queue and draws the alert or the evacuation banner. Each alert carries `panel_url`, the link to its page in the SafeOnuba dashboard, in case the operator needs the detail. 3. **Occasional API calls** when they are needed: the list of who is missing in an evacuation, acknowledging an alert, or `GET /v1/events` to catch up after an outage. From the moment an SOS goes off until it reaches your receiver, it normally takes 1 or 2 seconds. That is a typical measurement, not a service commitment. ## Using this documentation with AI If you integrate with an AI assistant, give it the documentation in Markdown rather than the URL: it reads the whole thing, without menus or tabs, and with the links already complete. - The **Copy** button, at the top right of each page, copies that page as Markdown. - Every page has its text version by adding `.md` to the address: [/en/developers/webhooks.md](https://safeonuba.com/en/developers/webhooks.md). - [/en/llms.txt](https://safeonuba.com/en/llms.txt) is the index for tools that read it on their own, and [/en/llms-full.txt](https://safeonuba.com/en/llms-full.txt) is all the documentation as a single text. - To generate typed code, the [OpenAPI 3.1 contract](https://safeonuba.com/en/developers/openapi.json) is the source of truth. ## All operations | Operation | What it does | Scope | | --- | --- | --- | | [`POST /v1/oauth/token`](https://safeonuba.com/en/developers/reference/authentication#createToken) | Request a token (client_credentials) | No token | | [`GET /v1/sites`](https://safeonuba.com/en/developers/reference/sites#listSites) | The client’s sites | Any | | [`GET /v1/sites/{id}/zones`](https://safeonuba.com/en/developers/reference/sites#listSiteZones) | A site’s zones and muster points | `alerts:read` | | [`GET /v1/alerts`](https://safeonuba.com/en/developers/reference/alerts#listAlerts) | List alerts | `alerts:read` | | [`GET /v1/alerts/{id}`](https://safeonuba.com/en/developers/reference/alerts#getAlert) | Alert detail | `alerts:read` | | [`PUT /v1/alerts/{id}`](https://safeonuba.com/en/developers/reference/alerts#updateAlert) | Acknowledge or resolve an alert | `alerts:write` | | [`GET /v1/musters`](https://safeonuba.com/en/developers/reference/evacuations#listMusters) | List evacuations | `musters:read` | | [`GET /v1/musters/{id}`](https://safeonuba.com/en/developers/reference/evacuations#getMuster) | Status and headcount of an evacuation | `musters:read` | | [`GET /v1/musters/{id}/missing`](https://safeonuba.com/en/developers/reference/evacuations#listMissingWorkers) | Who is missing | `musters:read` | | [`GET /v1/workers`](https://safeonuba.com/en/developers/reference/workers#listWorkers) | List workers | `workers:read` | | [`GET /v1/workers/{id}`](https://safeonuba.com/en/developers/reference/workers#getWorker) | Worker detail | `workers:read` | | [`GET /v1/devices`](https://safeonuba.com/en/developers/reference/workers#listDevices) | List watches | `workers:read` | | [`GET /v1/positions/latest`](https://safeonuba.com/en/developers/reference/positions#listLatestPositions) | Latest visible positions | `positions:read` | | [`GET /v1/events`](https://safeonuba.com/en/developers/reference/events#listEvents) | Replay events | Any | | [`GET /v1/webhooks`](https://safeonuba.com/en/developers/reference/webhooks#listWebhooks) | List destinations | `webhooks:manage` | | [`POST /v1/webhooks`](https://safeonuba.com/en/developers/reference/webhooks#createWebhook) | Create a destination | `webhooks:manage` | | [`DELETE /v1/webhooks/{id}`](https://safeonuba.com/en/developers/reference/webhooks#deleteWebhook) | Delete a destination | `webhooks:manage` | | [`POST /v1/webhooks/{id}/rotate-secret`](https://safeonuba.com/en/developers/reference/webhooks#rotateWebhookSecret) | Rotate the signing secret | `webhooks:manage` | | [`POST /v1/webhooks/{id}/test`](https://safeonuba.com/en/developers/reference/webhooks#testWebhook) | Send a test event | `webhooks:manage` | | [`POST /v1/sandbox/scenarios`](https://safeonuba.com/en/developers/reference/sandbox#runSandboxScenario) | Launch a test scenario | Any | ## Next step [Getting started](https://safeonuba.com/en/developers/getting-started): from access to your first webhook in five minutes. --- # Getting started > From access to your first webhook in five minutes: request a token, make your first call, create a destination and trigger a test alert in the sandbox. Page: https://safeonuba.com/en/developers/getting-started · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. This guide goes from access to your first received alert, all in the sandbox. You need a terminal with `curl` and, for step 5, a public https URL where you can receive webhooks. The examples read the credentials from environment variables. Don’t write them in the code. ```bash export SAFEONUBA_CLIENT_ID="example-client" export SAFEONUBA_CLIENT_SECRET="cs_…" ``` ## 1. Request access We register access ourselves, customer by customer, and the reason is recorded. The plant sees in its dashboard (Settings → Integrations) who has access, to what and why. When you are registered you receive a `client_id` and a `client_secret`, which starts with `cs_`. **The secret is shown only once**: store it in your secrets manager at that moment. To request it, write to us at [info@safeonuba.com](mailto:info@safeonuba.com?subject=SafeOnuba%20API%20access) with your company, the plant and what you want to integrate. ## 2. Request a token The token is requested with OAuth2 `client_credentials` and lasts one hour. ```bash curl -X POST https://sandbox.api.safeonuba.com/v1/oauth/token \ -d grant_type=client_credentials \ -d client_id="$SAFEONUBA_CLIENT_ID" \ -d client_secret="$SAFEONUBA_CLIENT_SECRET" ``` ```javascript const res = await fetch('https://sandbox.api.safeonuba.com/v1/oauth/token', { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: process.env.SAFEONUBA_CLIENT_ID, client_secret: process.env.SAFEONUBA_CLIENT_SECRET, }), }); const { access_token } = await res.json(); ``` ```python import os import requests res = requests.post( "https://sandbox.api.safeonuba.com/v1/oauth/token", data={ "grant_type": "client_credentials", "client_id": os.environ["SAFEONUBA_CLIENT_ID"], "client_secret": os.environ["SAFEONUBA_CLIENT_SECRET"], }, timeout=10, ) res.raise_for_status() access_token = res.json()["access_token"] ``` ```csharp using System.Net.Http.Json; using System.Text.Json; using var http = new HttpClient(); var res = await http.PostAsync("https://sandbox.api.safeonuba.com/v1/oauth/token", new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "client_credentials", ["client_id"] = Environment.GetEnvironmentVariable("SAFEONUBA_CLIENT_ID")!, ["client_secret"] = Environment.GetEnvironmentVariable("SAFEONUBA_CLIENT_SECRET")!, })); res.EnsureSuccessStatusCode(); var token = (await res.Content.ReadFromJsonAsync()).GetProperty("access_token").GetString(); ``` The response: ```json { "access_token": "at_9c1e…", "token_type": "Bearer", "expires_in": 3600, "scope": "alerts:read alerts:write musters:read" } ``` Keep the `access_token` for the next steps: ```bash export SAFEONUBA_TOKEN="…" ``` ## 3. Make your first call `GET /v1/sites` returns the sites you have access to. Any scope will do, so it is the best way to check that the token works. ```bash curl https://sandbox.api.safeonuba.com/v1/sites \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` Note the `id` of your sandbox site: you will use it when you run a scenario. ## 4. Create a webhook destination A destination is the URL of your receiver and the events you want it to get. It needs the `webhooks:manage` scope. ```bash curl -X POST https://sandbox.api.safeonuba.com/v1/webhooks \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "url": "https://controlroom.customer.example/safeonuba", "event_types": ["alert.*", "muster.*"] }' ``` The response includes the signing `secret`, which starts with `whsec_`. > **The webhook secret is only shown now** > > Store it in the same place as the `client_secret`. If you lose it, rotate it with `POST /v1/webhooks/{id}/rotate-secret`. ## 5. Receive and verify Your receiver has to do three things with each delivery: 1. Check the signature in `X-SafeOnuba-Signature` over the **raw** body. 2. Respond `2xx` in under 5 seconds. 3. Process the event afterwards, outside that request. [Verifying a webhook signature](https://safeonuba.com/en/developers/webhooks/verify-signature) has the complete receiver in Node, Python and C#. Once you have it deployed, check that something reaches it: ```bash curl -X POST https://sandbox.api.safeonuba.com/v1/webhooks/DESTINATION_ID/test \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` An `alert.created` arrives, marked `simulated: true` and signed like a real one. ## 6. Trigger a test SOS In the sandbox, a scenario makes a simulated watch press the SOS. What you receive is the same as you would receive from a real watch. ```bash curl -X POST https://sandbox.api.safeonuba.com/v1/sandbox/scenarios \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "scenario": "sos" }' ``` Your receiver gets an `alert.created` with the complete alert in `data.alert`. If nobody closes it, it closes itself after 10 minutes. ## 7. Acknowledge it from your control room With the `alerts:write` scope, your control room can take charge of the alert. Always on behalf of a person: the `actor` is the operator who acknowledged it, and is recorded in the alert’s timeline. ```bash curl -X PUT https://sandbox.api.safeonuba.com/v1/alerts/ALERT_ID \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "status": "acknowledged", "actor": { "name": "E. Example", "external_id": "OP-117" } }' ``` Your receiver gets `alert.acknowledged`, and in the SafeOnuba dashboard the alert shows as acknowledged “via integration”. ## Next steps - [Authentication and scopes](https://safeonuba.com/en/developers/authentication): caching the token and rotating the secret without downtime. - [Webhooks](https://safeonuba.com/en/developers/webhooks): retries, duplicates, order and filters. - [Sandbox](https://safeonuba.com/en/developers/sandbox): the full evacuation scenario, from start to finish. --- # Authentication and scopes > OAuth2 client_credentials: how to request a token, which scopes exist, how to rotate the client_secret without downtime and which errors the token endpoint returns. Page: https://safeonuba.com/en/developers/authentication · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. The API uses OAuth2 with the `client_credentials` flow: your server exchanges its credentials for a one-hour token and sends that token on every call. ## Credentials Each customer is registered by hand, with a reason that is recorded. When you are registered you receive: - a `client_id`, which identifies your company; - a `client_secret`, which starts with `cs_` and **is shown only once**. A client can have **two active secrets at the same time**. That is what lets you rotate it without cutting the integration (see [Rotating the client_secret](https://safeonuba.com/en/developers/authentication#rotating-the-client-secret)). The `client_secret` lives on your server, in a secrets manager or an environment variable. Never in a browser, a desktop application or a repository. ## Requesting a token `POST /v1/oauth/token`, with the body as `application/x-www-form-urlencoded`: | Field | Required | Value | | --- | --- | --- | | `grant_type` | Yes | `client_credentials` | | `client_id` | Yes | Your client identifier. | | `client_secret` | Yes | Your secret. | | `scope` | No | Space-separated scopes, from among those your client has. Without it, the token carries all of your client’s scopes. | ```bash curl -X POST https://sandbox.api.safeonuba.com/v1/oauth/token \ -d grant_type=client_credentials \ -d client_id="$SAFEONUBA_CLIENT_ID" \ -d client_secret="$SAFEONUBA_CLIENT_SECRET" \ -d scope="alerts:read musters:read" ``` ```javascript const res = await fetch('https://sandbox.api.safeonuba.com/v1/oauth/token', { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: process.env.SAFEONUBA_CLIENT_ID, client_secret: process.env.SAFEONUBA_CLIENT_SECRET, scope: 'alerts:read musters:read', }), }); const token = await res.json(); ``` ```python import os import requests res = requests.post( "https://sandbox.api.safeonuba.com/v1/oauth/token", data={ "grant_type": "client_credentials", "client_id": os.environ["SAFEONUBA_CLIENT_ID"], "client_secret": os.environ["SAFEONUBA_CLIENT_SECRET"], "scope": "alerts:read musters:read", }, timeout=10, ) res.raise_for_status() token = res.json() ``` ```csharp using System.Net.Http.Json; using System.Text.Json; using var http = new HttpClient(); var res = await http.PostAsync("https://sandbox.api.safeonuba.com/v1/oauth/token", new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "client_credentials", ["client_id"] = Environment.GetEnvironmentVariable("SAFEONUBA_CLIENT_ID")!, ["client_secret"] = Environment.GetEnvironmentVariable("SAFEONUBA_CLIENT_SECRET")!, ["scope"] = "alerts:read musters:read", })); res.EnsureSuccessStatusCode(); var token = await res.Content.ReadFromJsonAsync(); ``` The response: | Field | What it is | | --- | --- | | `access_token` | The token. It is opaque: don’t try to read it, just send it back. | | `token_type` | Always `Bearer`. | | `expires_in` | Seconds of validity: `3600`. | | `scope` | The scopes the token carries, separated by spaces. | The token is revoked instantly if the client is deregistered. ## Using the token On every other call, in the `Authorization` header: ```http GET /v1/alerts?status=unacknowledged HTTP/1.1 Host: sandbox.api.safeonuba.com Authorization: Bearer ``` ## Renewing it `client_credentials` has no refresh token: when one expires you request another with the same credentials. Request one, keep it in memory and renew it a little before it expires. Don’t request a token for every call: the token endpoint has its own limit per IP. ```javascript let token = null; let expiresAt = 0; export async function safeOnubaToken() { // Renewed a minute before it expires. if (token && Date.now() < expiresAt - 60_000) return token; const res = await fetch('https://sandbox.api.safeonuba.com/v1/oauth/token', { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: process.env.SAFEONUBA_CLIENT_ID, client_secret: process.env.SAFEONUBA_CLIENT_SECRET, }), }); if (!res.ok) throw new Error(`token: ${res.status} ${await res.text()}`); const data = await res.json(); token = data.access_token; expiresAt = Date.now() + data.expires_in * 1000; return token; } ``` ```python import os import time import requests _token = None _expires_at = 0.0 def safeonuba_token() -> str: global _token, _expires_at # Renewed a minute before it expires. if _token and time.time() < _expires_at - 60: return _token res = requests.post( "https://sandbox.api.safeonuba.com/v1/oauth/token", data={ "grant_type": "client_credentials", "client_id": os.environ["SAFEONUBA_CLIENT_ID"], "client_secret": os.environ["SAFEONUBA_CLIENT_SECRET"], }, timeout=10, ) res.raise_for_status() data = res.json() _token = data["access_token"] _expires_at = time.time() + data["expires_in"] return _token ``` ```csharp using System.Net.Http.Json; using System.Text.Json; public sealed class SafeOnubaToken(HttpClient http) { private readonly SemaphoreSlim _lock = new(1, 1); private string? _token; private DateTimeOffset _expiresAt; public async Task FetchAsync() { await _lock.WaitAsync(); try { // Renewed a minute before it expires. if (_token is not null && DateTimeOffset.UtcNow < _expiresAt.AddMinutes(-1)) return _token; var res = await http.PostAsync("https://sandbox.api.safeonuba.com/v1/oauth/token", new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "client_credentials", ["client_id"] = Environment.GetEnvironmentVariable("SAFEONUBA_CLIENT_ID")!, ["client_secret"] = Environment.GetEnvironmentVariable("SAFEONUBA_CLIENT_SECRET")!, })); res.EnsureSuccessStatusCode(); var data = await res.Content.ReadFromJsonAsync(); _token = data.GetProperty("access_token").GetString()!; _expiresAt = DateTimeOffset.UtcNow.AddSeconds(data.GetProperty("expires_in").GetDouble()); return _token; } finally { _lock.Release(); } } } ``` If a call returns `401` with a token you thought was valid (because it has been revoked, for example), discard the one you have and request another, once. If it fails again, don’t retry in a loop: check the credentials. ## Scopes Each token carries the scopes your client has been granted, or the subset you ask for in `scope`. Ask only for those each service uses: the webhook receiver, for example, needs none, because it doesn’t call the API. | Scope | What for | Operations | | --- | --- | --- | | `alerts:read` | Read alerts and zones. | `GET /v1/sites/{id}/zones`, `GET /v1/alerts`, `GET /v1/alerts/{id}` | | `alerts:write` | Acknowledge and resolve alerts (always with the operator). | `PUT /v1/alerts/{id}` | | `positions:read` | Positions the privacy veil allows. | `GET /v1/positions/latest` | | `musters:read` | Evacuations and headcount. | `GET /v1/musters`, `GET /v1/musters/{id}`, `GET /v1/musters/{id}/missing` | | `workers:read` | Workers and watches. | `GET /v1/workers`, `GET /v1/workers/{id}`, `GET /v1/devices` | | `webhooks:manage` | Webhook destinations. | `GET /v1/webhooks`, `POST /v1/webhooks`, `DELETE /v1/webhooks/{id}`, `POST /v1/webhooks/{id}/rotate-secret`, `POST /v1/webhooks/{id}/test` | `GET /v1/sites`, `GET /v1/events` and `POST /v1/sandbox/scenarios` work with any valid token. `alerts:write` always acts on behalf of a person: the body of `PUT /v1/alerts/{id}` requires the `actor`, the operator in your control room who acknowledges or closes the alert. ## Rotating the client_secret Because a client can have two active secrets, rotation cuts nothing: 1. Request a new secret through the same channel you used to request access. 2. Deploy the new one to your services. In the meantime, those still using the old one keep working. 3. Check that all tokens are now requested with the new one. 4. Ask for the old one to be withdrawn. The webhook signing secret is a different thing and is rotated through the API: see [Verifying a webhook signature](https://safeonuba.com/en/developers/webhooks/verify-signature#rotating-the-secret). ## Token errors The token endpoint responds in the OAuth2 format ([RFC 6749 §5.2](https://www.rfc-editor.org/rfc/rfc6749#section-5.2)), not in `problem+json`: an `error` field and, sometimes, `error_description`. It responds `401` if the client or the secret are wrong, and `400` for everything else. | error | What it means | | --- | --- | | `invalid_request` | A required field is missing or the request is malformed. | | `invalid_client` | The client_id or the client_secret is wrong, or the client has been deregistered. | | `unauthorized_client` | The client is not authorised to use this grant type. | | `unsupported_grant_type` | grant_type is not client_credentials. | | `invalid_scope` | A scope was requested that does not exist or that the client does not have. | On the rest of the API, a token that is missing, has expired or has been revoked gives `401`, and a token without the scope the path needs gives `403`. See [Errors](https://safeonuba.com/en/developers/errors). --- # Sites and zones > A site is one of the customer’s plants. Its zones and muster points come as GeoJSON, and its privacy level decides which positions the API can see. Page: https://safeonuba.com/en/developers/concepts/sites · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. A **site** is a plant: a location with its perimeter, its zones and its muster points. Your customer can have one or several, and everything else hangs from them: every alert, every evacuation and every event carries its own. ## Sites `GET /v1/sites` returns the sites your client has access to. Any scope will do. | Field | What it is | | --- | --- | | `id` | The site’s UUID. It’s the one you use in the `site_id` filters. | | `name` | The site’s name. | | `location` | Location as text, or `null`. | | `privacy_mode` | The plant’s privacy level. It decides which positions the API can see. | | `unveil_min_severity` | In “emergencies only”, the minimum severity of an alert from which that person’s position is visible. | | privacy_mode | What it means | | --- | --- | | `continuous` | The plant sees positions continuously, and so does the API. | | `on_demand` | “Emergencies only”, the default value. A person’s position is only visible with an open alert from `unveil_min_severity` upwards, during an evacuation or with an approved request. | The API obeys the same rule as the dashboard. [Positions](https://safeonuba.com/en/developers/concepts/positions) explains it in detail. ## Zones and muster points `GET /v1/sites/{id}/zones` returns a site’s zones in force. It requires `alerts:read`. Each zone comes with its geometry as a **GeoJSON polygon in WGS84** (`geometry.type: "Polygon"`), so you can draw it straight onto your control room’s plan or map. | type | What it means | | --- | --- | | `work_area` | Work area. | | `restricted` | Restricted zone. Entering it raises a `restricted_zone_entry` alert. | | `forbidden` | Off-limits zone. Entering it raises a `forbidden_zone_entry` alert. | | `assembly_point` | Muster point for an evacuation. It also carries the `assembly_point` object. | | `privacy` | Privacy zone, such as changing rooms or canteens. It arrives with a name and a type, but without geometry (`geometry: null`). | | `building` | Building. | ### Privacy zones Inside a privacy zone, nobody’s position is recorded. The API gives its name and its type, but not its polygon. ### Muster points Zones of type `assembly_point` also carry: | Field | What it is | | --- | --- | | `serves` | Which evacuations it is valid for: `general`, `tsunami`, `earthquake`. | | `elevation_m` | Elevation in metres, or `null`. | | `elevation_source` | Where the elevation comes from: `dem` or `verified`. | | `vertical` | `true` if it is an elevated refuge, inside a building. | | `capacity` | Capacity, or `null`. | During an evacuation, the control room can rule out muster points. That doesn’t change the zones: you see it in the evacuation itself (see [Evacuations and headcount](https://safeonuba.com/en/developers/concepts/evacuations#muster-points)). ## Filtering by site If your control room only covers one plant, filter by it instead of receiving everything: - In lists, with the `site_id` parameter (`/v1/alerts`, `/v1/musters`, `/v1/workers`, `/v1/devices`, `/v1/positions/latest`). - In webhooks, with `filters.site_ids` when you create the destination (see [Webhooks](https://safeonuba.com/en/developers/webhooks#filters)). Requesting a site that isn’t your customer’s returns `403` or `404`, depending on the route. --- # Alerts > Alert types, severity and states; the difference between the control room taking charge and the worker seeing the alert; and how to acknowledge or close it. Page: https://safeonuba.com/en/developers/concepts/alerts · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. An **alert** is something that has happened in the plant and that someone has to deal with: an SOS, a fall, someone entering where they shouldn’t, a watch with a flat battery or an earthquake warning. It’s the central object of the API. ## The object The alert arrives complete in every webhook (`data.alert`) and is what `GET /v1/alerts/{id}` returns. The most commonly used fields: | Field | What it is | | --- | --- | | `type`, `severity`, `status` | What has happened, how serious it is and where it stands in the response. | | `title` | The title the control room reads. | | `site`, `zone` | Where. `zone` can be `null`. | | `worker`, `device` | Who it happened to and with which watch. `null` in plant-wide alerts. | | `location`, `location_withheld` | Where the person is, if the plant’s privacy level allows it to be seen. | | `details` | Data specific to each type. | | `acknowledged`, `resolved`, `assigned_to` | Who has taken charge, who has closed it and who it is assigned to. | | `date_created`, `date_last_modified` | When it was created and when it last changed. | | `simulated` | `true` if it comes from a drill or from simulated watches. | | `panel_url` | Link to its page in the SafeOnuba dashboard. | The full list, field by field, is in the schema [`Alert`](https://safeonuba.com/en/developers/reference/schemas#Alert). ## Types | type | What it means | | --- | --- | | `sos` | The person has asked for help from the watch, or the control room has raised it on their behalf. | | `fall_detected` | The watch has detected a fall. | | `restricted_zone_entry` | Entry into a restricted zone. | | `forbidden_zone_entry` | Entry into an off-limits zone. | | `permit_expired_inside` | The permit to work has expired with the person still inside the zone. | | `permit_closed_inside` | The permit to work has been closed with the person still inside the zone. | | `heat_stress` | Heat stress: the watch has told them to stop and rest. | | `evacuation_help` | During an evacuation, the person says from the watch that they can’t get out on their own or that they are helping someone else. | | `worker_call_request` | The person alerts the control room from the watch. Without coverage, the watch records a voice message that can be heard in the dashboard. | | `device_offline` | The watch hasn’t communicated for a while. | | `low_battery` | The watch is low on battery. | | `earthquake` | Earthquake. It is plant-wide: it carries no `worker` or `device`. | | `tsunami_risk` | Tsunami risk. It is plant-wide: it carries no `worker` or `device`. | ### A nearby vehicle is not an alert Nearby-vehicle warnings don’t generate alerts of their own. They go inside the alert for an incident, in two lists: - `nearby`: who was within 100 metres when a critical alert (SOS or fall) was raised, people or vehicles. It gives the distance, never the position, and doesn’t count anyone who was in a privacy zone. - `vehicle_warnings`: the nearby-vehicle warnings that person received in the incident window, with the distance and whether they saw them on the watch. ### Details for each type `details` changes according to `type`. For example, [`SosDetails`](https://safeonuba.com/en/developers/reference/schemas#SosDetails) says how the SOS was raised (`trigger`), [`ZoneDetails`](https://safeonuba.com/en/developers/reference/schemas#ZoneDetails) carries the permit to work that applied (`permit_reference`) and [`HazardDetails`](https://safeonuba.com/en/developers/reference/schemas#HazardDetails) the magnitude, the distance and whether the warning is official. The variants are in [`AlertDetails`](https://safeonuba.com/en/developers/reference/schemas#AlertDetails). > **There are never vital signs** > > Not in `details` and not anywhere else. [`HeatStressDetails`](https://safeonuba.com/en/developers/reference/schemas#HeatStressDetails) says what the worker has been told (`stop_and_rest`), the plant’s heat index and the minutes in the sun, nothing that comes from their pulse. ## Severity | severity | What it means | | --- | --- | | `low` | Information. | | `medium` | Warning. | | `high` | Serious. | | `critical` | Critical: SOS, fall, “I can’t evacuate”. | The `severity` filter of `GET /v1/alerts` and the `min_severity` of webhooks are **minimums**: `high` returns `high` and `critical`. ## States | status | What it means | | --- | --- | | `unacknowledged` | Nobody has taken charge yet. | | `acknowledged` | An operator has taken charge. | | `resolved` | Closed. | - **Acknowledging** assigns the alert to whoever acknowledges it, if nobody was handling it. Acknowledging is not resolving: an acknowledged alert keeps marking the person on the map until it is resolved. - **Resolving** records who, when and with what note, in `resolved`. - A resolved alert can be **reopened**. The `alert.reopened` event announces it. > **Two different “seen”** > > `status: "acknowledged"` means that a control room operator has taken charge. `worker_acknowledged_at` is something else: the time (from the watch) at which the worker tapped “Got it” on their wrist. Each has its own event: `alert.acknowledged` and `alert.worker_acknowledged`. ## Acknowledging and resolving from your control room `PUT /v1/alerts/{id}` with the `alerts:write` scope. The body: | Field | Required | What it is | | --- | --- | --- | | `status` | Yes | `acknowledged` or `resolved`. | | `actor` | Yes | Your control room’s operator: `name` and, if you want, `external_id`. | | `resolution_reason` | No | When resolving, why. Up to 200 characters. | | `note` | No | A note. Up to 500 characters. | The operator is required because “acknowledged” measures a person’s response. In the alert’s timeline it appears as “Customer · Operator (via integration)”. ```bash curl -X PUT https://sandbox.api.safeonuba.com/v1/alerts/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90 \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "status": "resolved", "resolution_reason": "False alarm", "note": "Confirmed by radio with the supervisor.", "actor": { "name": "E. Example", "external_id": "OP-117" } }' ``` ```javascript const res = await fetch(`https://sandbox.api.safeonuba.com/v1/alerts/${alertId}`, { method: 'PUT', headers: { Authorization: `Bearer ${await safeOnubaToken()}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 'resolved', resolution_reason: 'False alarm', note: 'Confirmed by radio with the supervisor.', actor: { name: currentOperator.name, external_id: currentOperator.id }, }), }); if (res.status === 409) { // It was already resolved, or the transition isn't possible: nothing to do. } else if (!res.ok) { throw new Error(`${res.status}: ${await res.text()}`); } ``` ```python res = requests.put( f"https://sandbox.api.safeonuba.com/v1/alerts/{alert_id}", headers={"Authorization": f"Bearer {safeonuba_token()}"}, json={ "status": "resolved", "resolution_reason": "False alarm", "note": "Confirmed by radio with the supervisor.", "actor": {"name": current_operator.name, "external_id": current_operator.id}, }, timeout=10, ) if res.status_code != 409: # 409: it was already resolved res.raise_for_status() ``` ```csharp var body = JsonContent.Create(new { status = "resolved", resolution_reason = "False alarm", note = "Confirmed by radio with the supervisor.", actor = new { name = currentOperator.Name, external_id = currentOperator.Id }, }); var res = await http.PutAsync($"https://sandbox.api.safeonuba.com/v1/alerts/{alertId}", body); if (res.StatusCode != System.Net.HttpStatusCode.Conflict) res.EnsureSuccessStatusCode(); ``` The response is the updated alert. If the transition isn’t possible —for example, resolving or acknowledging an alert that is already resolved— the API responds `409`. ## Querying alerts `GET /v1/alerts` returns alerts from newest to oldest, paginated. Filters: | Parameter | What it filters | | --- | --- | | `site_id` | A site. | | `type` | A type. | | `status` | A state. | | `severity` | Minimum severity. | | `since`, `until` | Date range, in ISO 8601. | | `limit`, `cursor` | Pagination (see [Pagination](https://safeonuba.com/en/developers/pagination)). | To follow alerts in real time, don’t poll in a loop: use [webhooks](https://safeonuba.com/en/developers/webhooks). The list is for loading the state at start-up and for searching. ## Worker names Names are optional by contract. If your contract doesn’t include them, `worker.name` doesn’t appear and `title` is built from the type, the zone and the company identifier (`external_id`), without a name. See [Workers and watches](https://safeonuba.com/en/developers/concepts/workers-and-watches). --- # Evacuations and headcount > Evacuation types, states and phases, the headcount by person and by muster point, ruled-out points and the list of who is missing. Page: https://safeonuba.com/en/developers/concepts/evacuations · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. An **evacuation** (`muster` in the API) is the headcount of a plant when people have to get out: who has reached a valid muster point, who is asking for help and who is missing. It’s what matters most to get right in your control room, and that’s why its events go out ahead of almost everything else. > **The API doesn’t start evacuations** > > In v1 an evacuation always starts in the SafeOnuba dashboard: the system can propose it, but a person decides. Your control room receives it as soon as it starts (`muster.started`) and follows it until it ends. ## Type, status and phase | kind | What it means | | --- | --- | | `general` | General evacuation. | | `tsunami` | For a tsunami. It can carry `deadline`, the time limit. | | `earthquake` | For an earthquake. | The type can change during the evacuation (`muster.kind_changed`), and with it the muster points that are valid. | status | What it means | | --- | --- | | `active` | Under way. | | `completed` | Finished. | | `cancelled` | Cancelled. | | phase | What it means | | --- | --- | | `evacuating` | People are being evacuated. | | `inspecting` | The headcount is complete and the control room is checking the plant. The watches say “don’t go back until further notice”. | Other useful fields: - `reason`: the reason, as text. - `origin`: where the emergency comes from (coordinates, radius and zone). `null` in an evacuation without an origin, such as a general drill. - `deadline`: in a tsunami, the time limit and where it comes from: `official` (an official warning) or `plan_estimate` (the ceiling in the zone’s plan). It is never a physical calculation. ## The headcount `GET /v1/musters/{id}` returns the evacuation with its headcount in `totals`, calculated on the server with the same rule as the dashboard and the close-out: a precise position inside a valid point, or a declared arrival at one. Each person is in one of these states: | State | What it means | | --- | --- | | `safe` | Safe at a valid muster point. | | `help` | Asking for help: trapped (`trapped`), injured (`injured`) or helping someone else (`helping`). | | `wrong_point` | At a muster point that isn’t valid for this type of evacuation, or that the control room has ruled out. | | `pending` | Hasn’t arrived yet. | | `no_signal` | Their watch isn’t communicating. | | `not_worn` | They aren’t wearing the watch. | `totals` carries `expected` (how many people are expected) and the number of people in each state. > **A closed evacuation keeps less** > > On closing, only the arrivals and the total are recorded. In a finished evacuation, the rest of the figures in `totals` and the `count` of each point come back `null`. ## Muster points `assembly_points` lists the destinations for the evacuation type, ruled-out ones included, with how many people are safe at each (`count`). The control room can **rule out** a point during the evacuation, for example because it falls inside the emergency’s radius or downwind, and can also reinstate it. A ruled-out point has `status: "excluded"` and an `excluded` object with: - `reason`: why; - `recommended`: whether the system had recommended it (by radius or by wind). Whoever rules it out is always a person; - `at` and `by`: when and who. Each change of destinations raises `targets_revision` and emits `muster.targets_changed`. Anyone who was at a ruled-out point moves to `wrong_point`. ## Who is missing `GET /v1/musters/{id}/missing` returns the people who aren’t safe, ordered by urgency: first whoever is asking for help or needs rescue, then whoever is at a point that isn’t valid, without signal, without the watch on and still on their way out. - `needs_rescue`: has an open SOS or fall and hasn’t arrived. They can’t get out on their own. - `help_reason`: `trapped`, `injured` or `helping`. - `assembly_point`: the point where they are or said they were, with `valid: false` if it isn’t valid. - `location`: the last position, only with the `positions:read` scope and if the plant’s privacy level allows it to be seen. Otherwise, `location_withheld: true`. ## Evacuation events | Event | When | | --- | --- | | `muster.started` | The evacuation starts. | | `muster.kind_changed` | The type changes. | | `muster.phase_changed` | The phase changes (`evacuating`, `inspecting`). | | `muster.targets_changed` | The control room has ruled out or reinstated a muster point. | | `muster.updated` | The headcount has changed. It is checked every 5 seconds and only emitted when it changes. | | `muster.worker_missing` | Someone is missing. | | `muster.ended` | It ends. | A person who asks for help during the evacuation also generates an `evacuation_help` alert (`alert.created`), which carries the `muster_id` in its details. Every event carries the complete evacuation in `data.muster`, so your control room can redraw the banner with the latest one that arrives. Since delivery order isn’t guaranteed, keep the most recent by `created_at` (see [Webhooks](https://safeonuba.com/en/developers/webhooks#order)). ## Testing it without evacuating anyone The sandbox has a scenario that runs through an entire evacuation in 3 minutes: arrivals, a ruled-out point, a request for help and the close. See [Sandbox](https://safeonuba.com/en/developers/sandbox#a-complete-evacuation). --- # Workers and watches > How the API identifies each person and their watch: company identifier, optional name by contract, battery, whether it’s being worn and last connection. Page: https://safeonuba.com/en/developers/concepts/workers-and-watches · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. Every person wears a watch. The API exposes them separately —workers and watches— and links them to each other. Both require the `workers:read` scope. ## Workers `GET /v1/workers` and `GET /v1/workers/{id}`. | Field | What it is | | --- | --- | | `id` | The worker’s UUID in SafeOnuba. | | `external_id` | Their code in your company, the one from your personnel system. `null` if none has been given. | | `name` | Full name. **Only if the contract includes it**; otherwise the property doesn’t appear. | | `contractor` | The contractor they work for, or `null`. | | `role` | Their job, or `null`. | | `is_active` | Whether they are registered. | | `device` | The watch assigned to them. | | `date_last_seen` | The more recent of their last position and the watch’s last contact. | ### No names Workers’ names are optional by contract. Without them, the API identifies each person by their `external_id`, the same code your company already uses. To cross-reference with your personnel or access control system, use that field, which doesn’t depend on the contract including names. The same approach applies everywhere: in `worker` inside an alert, in the list of who is missing and in positions. ### What your client sees The API sees the same as a person in the dashboard with those permissions, including the scope by contractor. A worker your identity can’t see responds `404`, as if they didn’t exist. ## Watches `GET /v1/devices` returns the watches with their status. | Field | What it is | | --- | --- | | `id`, `serial` | Identifier and serial number. | | `model` | Model, or `null`. | | `status` | The watch’s status in the inventory. | | `battery` | Battery as a percentage, or `null`. | | `worn` | Whether it is being worn. `null` if unknown, because the watch has no wrist sensor. | | `date_last_seen` | Last contact. | | `worker` | Who it is assigned to. | | status | What it means | | --- | --- | | `active` | In use. | | `inactive` | Out of use. | | `maintenance` | Under maintenance. | | `lost` | Lost. | A watch that stops communicating or runs out of battery generates an alert (`device_offline`, `low_battery`). You don’t need to poll this list in a loop to find out: they arrive by webhook like any other alert. ## There are never vital signs Neither the worker nor the watch carries pulse, temperature or any other vital sign, individually or aggregated. See [Privacy and security](https://safeonuba.com/en/developers/privacy-and-security). --- # Positions > When the API returns a position, what location_withheld means, how privacy zones are represented and how accurate each point is. Page: https://safeonuba.com/en/developers/concepts/positions · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. A position is where a person is. It’s the most sensitive data in the API, and that’s why it follows exactly the same rule as the dashboard: if a person in the dashboard with those permissions can’t see it, neither can the API. ## When a position is visible It is decided by each site’s privacy level (`privacy_mode` in `GET /v1/sites`): - **“Emergencies only”** (`on_demand`), the default value. A person’s position is only visible when they have an open alert with severity equal to or above `unveil_min_severity`, during an evacuation or with a request approved in the dashboard. - **Continuous** (`continuous`). The plant has chosen to see positions continuously. Inside a privacy zone (changing rooms, canteens), nobody’s position is recorded, at any level. ## In an alert: location_withheld Every alert carries `location` and `location_withheld`: - If the privacy level allows the position to be seen, `location` carries it and `location_withheld` is `false`. - If the position exists but isn’t delivered —in “emergencies only”, an alert below the severity that lifts the veil—, `location_withheld` is `true`. It isn’t visible in the dashboard either. Your control room has to handle both cases. An alert without a position isn’t an error: it’s the plant protecting its people. For that case, `panel_url` leads to the alert’s page in the dashboard. ## Latest positions `GET /v1/positions/latest` (scope `positions:read`) returns the positions that the privacy level allows to be seen **at that moment**. In “emergencies only”, anyone with nothing open doesn’t appear. | Field | What it is | | --- | --- | | `device_id` | The watch. | | `worker` | The person. | | `location` | The position. | | `in_vehicle` | Whether they are in a vehicle. | Every position served with an alert behind it is recorded in that alert, under “Who has seen their position”. ## The location object | Field | What it is | | --- | --- | | `latitude`, `longitude` | WGS84 coordinates. | | `uncertainty_m` | Radius of uncertainty in metres. `null` if unknown, which isn’t the same as having it perfect. | | `source` | Where the point comes from. | | `masked` | `true` if the point is the representative one for a privacy zone and not the person’s own. | | `position_date_utc` | When it was taken. | | `height` | Height above the ground by barometer, with its uncertainty, building and floor, when it can be stated. Otherwise, `null`. | | source | What it means | | --- | --- | | `gps` | The watch’s GPS. | | `network` | Position by network. | | `zone` | The person was in a privacy zone: the point is the zone’s, not theirs. | Always draw the `uncertainty_m` radius next to the point: a point without a radius gives a precision it doesn’t have. ## Distances without a position In a critical alert, `nearby` says who was within 100 metres and at what distance, **never where**. Anyone who was in a privacy zone doesn’t count. See [Alerts](https://safeonuba.com/en/developers/concepts/alerts#a-nearby-vehicle-is-not-an-alert). ## Don’t track anyone in a loop Querying `GET /v1/positions/latest` every few seconds gives you nothing more than the alerts: in “emergencies only” only people who already have an open alert appear, and that alert already reaches you by webhook with the position inside. On top of that, every query counts towards your [rate limits](https://safeonuba.com/en/developers/rate-limits). --- # Webhooks > Create a destination, understand the format of each delivery, respond in time, deduplicate, filter by site or severity and send a test event. Page: https://safeonuba.com/en/developers/webhooks · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. With a webhook, SafeOnuba tells your server as soon as something happens: a new alert, an operator taking charge, a change in an evacuation’s headcount. Your control room doesn’t have to ask, and the alert reaches it in a second or two. ## Create a destination A destination is a URL of yours and the events you want to receive on it. You create it with `POST /v1/webhooks` and the `webhooks:manage` scope. | Field | Required | What it is | | --- | --- | --- | | `url` | Yes | Your receiver. `https` only, and at a public address. | | `event_types` | Yes | The events: whole groups (`alert.*`, `muster.*`) or specific types (`alert.created`). | | `filters` | No | Which part of those events: by site, severity or contractor. See [Filters](https://safeonuba.com/en/developers/webhooks#filters). | ```bash curl -X POST https://sandbox.api.safeonuba.com/v1/webhooks \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "url": "https://sala.cliente.example/safeonuba", "event_types": ["alert.*", "muster.*"], "filters": { "min_severity": "high" } }' ``` ```javascript const res = await fetch('https://sandbox.api.safeonuba.com/v1/webhooks', { method: 'POST', headers: { Authorization: `Bearer ${await safeOnubaToken()}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ url: 'https://sala.cliente.example/safeonuba', event_types: ['alert.*', 'muster.*'], filters: { min_severity: 'high' }, }), }); const destination = await res.json(); // destination.secret only arrives this once: store it now. ``` ```python res = requests.post( "https://sandbox.api.safeonuba.com/v1/webhooks", headers={"Authorization": f"Bearer {safeonuba_token()}"}, json={ "url": "https://sala.cliente.example/safeonuba", "event_types": ["alert.*", "muster.*"], "filters": {"min_severity": "high"}, }, timeout=10, ) res.raise_for_status() destination = res.json() # destination["secret"] only arrives this once: store it now. ``` ```csharp var res = await http.PostAsync("https://sandbox.api.safeonuba.com/v1/webhooks", JsonContent.Create(new { url = "https://sala.cliente.example/safeonuba", event_types = new[] { "alert.*", "muster.*" }, filters = new { min_severity = "high" }, })); res.EnsureSuccessStatusCode(); var destination = await res.Content.ReadFromJsonAsync(); // destination.GetProperty("secret") only arrives this once: store it now. ``` The response is the destination with one more field, `secret`, which starts with `whsec_`. **It is only shown this once.** It is the secret you use to [verify the signature](https://safeonuba.com/en/developers/webhooks/verify-signature) of each delivery. ## Delivery format Each event is a `POST` to your URL with a JSON body and these headers: | Header | What it carries | | --- | --- | | `X-SafeOnuba-Event-Id` | The event’s `id`. | | `X-SafeOnuba-Timestamp` | When it was signed, in Unix seconds. | | `X-SafeOnuba-Signature` | `v1=`. During a secret rotation, two signatures separated by a comma. | | `User-Agent` | `SafeOnuba-Webhooks/2026-09-24` | The body is always the same envelope: | Field | What it is | | --- | --- | | `id` | Event identifier. It’s the one you use to [deduplicate](https://safeonuba.com/en/developers/webhooks#duplicates). | | `type` | The event type (see [Events](https://safeonuba.com/en/developers/events#event-types)). | | `api_version` | `"2026-09-24"`. | | `created_at` | When it happened, in ISO 8601 UTC. | | `organization_id` | The site’s company. `null` in test events. | | `site_id` | The site. | | `data` | `{ "alert": … }` or `{ "muster": … }`: the complete object, exactly as the `GET` would return it at that moment. | ```json { "id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "type": "alert.created", "api_version": "2026-09-24", "created_at": "2026-09-24T11:04:12Z", "organization_id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "site_id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "data": { "alert": { "id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "type": "sos", "severity": "critical", "status": "unacknowledged", "title": "SOS activado — Ana Ejemplo", "date_created": "2026-09-24T11:04:12Z", "date_last_modified": "2026-09-24T11:04:12Z", "site": { "id": "11111111-1111-1111-1111-111111111111", "name": "Planta Ejemplo — simulación" }, "zone": null, "worker": { "id": "33333333-3333-3333-3333-333333333305", "external_id": "EMP-004512", "name": "Ana Ejemplo", "contractor": "Contrata Ejemplo A" }, "device": { "id": "44444444-4444-4444-4444-444444444405", "serial": "OS-W-0005", "battery": 64, "date_last_seen": "2026-09-24T11:04:10Z" }, "location": { "latitude": 40.00121, "longitude": -3.00214, "uncertainty_m": 8, "source": "gps", "masked": false, "position_date_utc": "2026-09-24T11:04:08Z", "height": null }, "location_withheld": false, "details": { "trigger": "watch_button" }, "nearby": [ { "kind": "vehicle", "worker": { "id": "33333333-3333-3333-3333-333333333309", "external_id": "EMP-002210", "name": "Carlos Ejemplo", "contractor": "Contrata Ejemplo B" }, "vehicle_type": "Grúa móvil", "distance_m": 18, "reported_at": "2026-09-24T11:04:02Z" }, { "kind": "person", "worker": { "id": "33333333-3333-3333-3333-333333333301", "external_id": "EMP-000981", "name": "Diego Ejemplo", "contractor": null }, "vehicle_type": null, "distance_m": 26, "reported_at": "2026-09-24T11:03:58Z" } ], "vehicle_warnings": [], "worker_acknowledged_at": null, "acknowledged": null, "resolved": null, "assigned_to": null, "simulated": false, "panel_url": "https://app.safeonuba.com/alertas/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90" } } } ``` Since `data` carries the whole object, you normally don’t need to call the API when you receive an event: you have everything your control room needs to display it. ## Responding in time A delivery counts as delivered when your receiver responds with **any `2xx` in under 5 seconds**. Redirects aren’t followed: a `301` or a `302` is a failure. That’s why your receiver should do the bare minimum: verify the signature, put the event on a queue (a table, Redis, RabbitMQ, whatever you already use) and respond. Process it afterwards, outside the request. If your control-room system is slow or goes down, the queue holds and SafeOnuba doesn’t retry for nothing. ## Retries If your receiver doesn’t respond `2xx` in time, the delivery is retried after **10 s, 30 s, 2 min, 10 min, 30 min and 1 h**, and then **every hour up to 24 hours** from the event. If a delivery is given up as lost, we email your company’s technical contact. The event remains available in `GET /v1/events` for 30 days (see [Catching up on what you missed](https://safeonuba.com/en/developers/events#catching-up-on-what-you-missed)). A destination that fails **doesn’t switch itself off**: if it did, the next SOS would stop arriving without anyone having decided that. Instead, `failing_since` tells you how long it has been failing. Keep an eye on it with `GET /v1/webhooks`. ## Duplicates Delivery is **“at least once”**: the same event can arrive more than once, for example if your receiver processed it but the response didn’t arrive in time. Deduplicate by the event’s `id` (it also comes in `X-SafeOnuba-Event-Id`). The simplest way is a table with the `id` as its key: ```sql CREATE TABLE safeonuba_events ( id uuid PRIMARY KEY, received_at timestamptz NOT NULL DEFAULT now() ); -- Returns a row if the event is new, none if it had already been received. INSERT INTO safeonuba_events (id) VALUES ($1) ON CONFLICT (id) DO NOTHING RETURNING id; ``` ## Order **Delivery order isn’t guaranteed.** The urgent goes first: SOS, falls, “I can’t evacuate” and evacuations jump ahead of the rest. And a retry can arrive after a newer event. Each event carries the complete object, so the rule is to keep the most recent and discard whatever arrives older: - To order events relative to each other, use their `created_at`. - For an alert, compare `date_last_modified`: if what arrives is older than what you already have, discard it. - If you detect a gap, for example after an outage, fill it with `GET /v1/events`. ```javascript const latestVersion = new Map(); // alert id → date_last_modified function applyAlert(alert) { const known = latestVersion.get(alert.id); if (known && Date.parse(known) >= Date.parse(alert.date_last_modified)) return; // older latestVersion.set(alert.id, alert.date_last_modified); showInControlRoom(alert); } ``` ## Filters With `filters` you receive only a part of the events you asked for: | Filter | What it does | | --- | --- | | `site_ids` | Only those sites. | | `min_severity` | Only alerts of that severity or higher (`low`, `medium`, `high`, `critical`). | | `contractors` | Only what affects those contractors. | You can have several destinations with different filters: for example, the critical alerts from every plant to the central control room, and everything from one plant to its own control room. ## Sending a test `POST /v1/webhooks/{id}/test` sends that destination an `alert.created` marked `simulated: true`, signed like a real one. It responds `202` when it queues it; you see the result on the destination, in `failing_since`. To test the whole journey, with real alerts and evacuations raised by simulated watches, use the [sandbox](https://safeonuba.com/en/developers/sandbox). ## Rotating the secret `POST /v1/webhooks/{id}/rotate-secret` gives you a new secret. The previous one keeps signing for 24 hours, and during that time each delivery carries both signatures. How to switch secrets without losing any event: [Verifying a webhook signature](https://safeonuba.com/en/developers/webhooks/verify-signature#rotating-the-secret). ## Managing destinations - `GET /v1/webhooks`: your destinations, with their events, filters, `is_active` and `failing_since`. - `DELETE /v1/webhooks/{id}`: deletes a destination. ## Webhooks or polling Use webhooks to find out what’s happening, and the API for the rest: loading the state at start-up, the list of who is missing, acknowledging an alert. Polling in a loop arrives later than a webhook and uses up your [rate limits](https://safeonuba.com/en/developers/rate-limits). --- # Verifying a webhook signature > How to check that a delivery comes from SafeOnuba: HMAC-SHA256 over the raw body, constant-time comparison, a 5-minute window and secret rotation. Page: https://safeonuba.com/en/developers/webhooks/verify-signature · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. Your receiver is at a public URL, so anyone can send it a `POST`. The signature is what proves a delivery comes from SafeOnuba and that nobody has tampered with it along the way. **Don’t process any event without verifying it.** ## How it’s signed Each delivery carries two headers: - `X-SafeOnuba-Timestamp`: the moment of signing, in Unix seconds. - `X-SafeOnuba-Signature`: `v1=` followed by the signature in hexadecimal. The signature is an HMAC-SHA256, with the destination’s secret (the `whsec_…` the API gave you when you created it) as the key, over the timestamp, a full stop and the body **exactly as it arrived**: ```text v1=hex( HMAC_SHA256( secret, timestamp + "." + raw_body ) ) ``` ## What your receiver has to do 1. **Read the raw body**, as bytes, before any framework turns it into JSON. If you parse it and serialise it again, the whitespace or the order of the keys changes and the signature no longer matches. 2. **Reject it if the timestamp is more than 5 minutes off** your clock, in either direction. This stops someone replaying an old event they captured. Keep your server’s clock synchronised by NTP. 3. **Calculate the signature** with your secret over `timestamp + "." + body`. 4. **Compare it in constant time** against each signature in the header. An ordinary comparison (`==`) takes a different time depending on how many characters match, and that leaks information. 5. **Accept it if any of the signatures in the header matches.** During a secret rotation two arrive, separated by a comma. If the signature isn’t valid, respond `401` and discard the body. ## A complete receiver The three examples do the same thing: they verify, put the event on your queue (`enqueue`, which you provide) and respond `204` straight away. ```javascript import { createHmac, timingSafeEqual } from 'node:crypto'; import { createServer } from 'node:http'; const SECRET = process.env.SAFEONUBA_WEBHOOK_SECRET; // whsec_… const TOLERANCE_S = 5 * 60; export function isValidSignature(body, timestamp, header, secret = SECRET) { if (!body || !timestamp || !header) return false; // 1. The timestamp, within 5 minutes of our clock. const t = Number(timestamp); if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > TOLERANCE_S) return false; // 2. The expected signature, over the raw body (a Buffer). const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest(); // 3. Any of the signatures in the header will do, compared in constant time. return header.split(',').some((part) => { const [version, hex] = part.trim().split('='); if (version !== 'v1' || !/^[0-9a-f]{64}$/i.test(hex ?? '')) return false; return timingSafeEqual(Buffer.from(hex, 'hex'), expected); }); } createServer((req, res) => { if (req.method !== 'POST' || req.url !== '/safeonuba') { res.writeHead(404).end(); return; } const chunks = []; req.on('data', (chunk) => chunks.push(chunk)); req.on('end', () => { const body = Buffer.concat(chunks); // raw: no JSON.parse before verifying const valid = isValidSignature( body, req.headers['x-safeonuba-timestamp'], req.headers['x-safeonuba-signature'], ); if (!valid) { res.writeHead(401).end(); return; } enqueue(JSON.parse(body.toString('utf8'))); // processed later, outside the request res.writeHead(204).end(); }); }).listen(8080); ``` ```python import hashlib import hmac import os import time from flask import Flask, abort, request SECRET = os.environ["SAFEONUBA_WEBHOOK_SECRET"].encode() # whsec_… TOLERANCE_S = 5 * 60 app = Flask(__name__) def is_valid_signature(body: bytes, timestamp: str | None, header: str | None) -> bool: if not timestamp or not header: return False # 1. The timestamp, within 5 minutes of our clock. try: t = int(timestamp) except ValueError: return False if abs(time.time() - t) > TOLERANCE_S: return False # 2. The expected signature, over the raw body. expected = hmac.new(SECRET, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest() # 3. Any of the signatures in the header will do, compared in constant time. for part in header.split(","): version, _, signature = part.strip().partition("=") if version == "v1" and hmac.compare_digest(signature.lower(), expected): return True return False @app.post("/safeonuba") def receive(): body = request.get_data() # raw, before touching request.json if not is_valid_signature( body, request.headers.get("X-SafeOnuba-Timestamp"), request.headers.get("X-SafeOnuba-Signature"), ): abort(401) enqueue(request.get_json()) # processed later, outside the request return "", 204 ``` ```csharp using System.Security.Cryptography; using System.Text; using System.Text.Json; var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); var secret = Encoding.UTF8.GetBytes(builder.Configuration["SAFEONUBA_WEBHOOK_SECRET"]!); // whsec_… app.MapPost("/safeonuba", async (HttpRequest req) => { // Raw: the whole body is read before deserialising anything. using var ms = new MemoryStream(); await req.Body.CopyToAsync(ms); var body = ms.ToArray(); if (!IsValidSignature(body, req.Headers["X-SafeOnuba-Timestamp"], req.Headers["X-SafeOnuba-Signature"], secret)) return Results.Unauthorized(); Enqueue(JsonDocument.Parse(body)); // processed later, outside the request return Results.NoContent(); }); app.Run(); static bool IsValidSignature(byte[] body, string? timestamp, string? header, byte[] secret) { if (string.IsNullOrEmpty(timestamp) || string.IsNullOrEmpty(header)) return false; // 1. The timestamp, within 5 minutes of our clock. if (!long.TryParse(timestamp, out var t)) return false; if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > 5 * 60) return false; // 2. The expected signature, over the raw body. var signed = Encoding.UTF8.GetBytes(timestamp + ".").Concat(body).ToArray(); var expected = HMACSHA256.HashData(secret, signed); // 3. Any of the signatures in the header will do, compared in constant time. foreach (var part in header.Split(',')) { var pieces = part.Trim().Split('=', 2); if (pieces.Length != 2 || pieces[0] != "v1") continue; byte[] received; try { received = Convert.FromHexString(pieces[1]); } catch (FormatException) { continue; } if (CryptographicOperations.FixedTimeEquals(received, expected)) return true; } return false; } ``` > **If you use a framework, switch off parsing on this route** > > Express, Fastify, Django REST or ASP.NET with `[FromBody]` turn the body into an object before it reaches your code, and from then on you can’t verify. In Express, for example, mount this route with `express.raw({ type: 'application/json' })` instead of `express.json()`. ## Rotating the secret Rotate the secret if you think it has leaked, when someone who knew it leaves, or simply every so often. Nothing is interrupted: 1. Call `POST /v1/webhooks/{id}/rotate-secret`. The response carries the new secret (`secret`, this once only) and how long the previous one keeps signing (`previous_valid_until`). 2. For the next **24 hours**, each delivery carries **two signatures** in `X-SafeOnuba-Signature`, one with each secret, separated by a comma: ```text v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd,v1=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39 ``` 3. Switch your receiver’s secret to the new one at any point in those 24 hours. Since the code above accepts the delivery if any one of the signatures matches, it works the same before and after the switch. 4. After the 24 hours, the previous secret stops signing. A second rotation retires the first secret: there are never more than two at once. ## Common mistakes - **Verifying over the re-serialised JSON.** The signature is calculated over the exact bytes that arrived. - **Comparing with `==`.** Use `timingSafeEqual`, `hmac.compare_digest` or `CryptographicOperations.FixedTimeEquals`. - **Ignoring the timestamp.** Without the 5-minute window, a captured event can be replayed whenever someone likes. - **Keeping only the first signature.** During a rotation two arrive, and the one that matches your secret may be the second. - **Responding after processing.** If your control room takes more than 5 seconds, the delivery counts as failed and is retried. Verify, enqueue and respond. --- # Events > Event types, the common envelope and how to recover missed events with /v1/events, which returns exactly what was sent by webhook. Page: https://safeonuba.com/en/developers/events · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. An event is a change: an alert being created, an operator acknowledging it, a headcount going up. The same events arrive by [webhook](https://safeonuba.com/en/developers/webhooks) and can be requested again with `GET /v1/events`. ## Event types | type | What it means | | --- | --- | | `alert.created` | An alert has been created. | | `alert.acknowledged` | An operator has taken charge of the alert, from the dashboard or from your control room. | | `alert.resolved` | The alert has been closed. | | `alert.reopened` | A closed alert has been reopened. | | `alert.worker_acknowledged` | The worker has seen the alert on their watch (`worker_acknowledged_at`). | | `muster.started` | An evacuation has started. | | `muster.kind_changed` | The evacuation type has changed (general, tsunami, earthquake). | | `muster.phase_changed` | The phase has changed: `evacuating` or `inspecting`. | | `muster.targets_changed` | The control room has ruled out or restored a muster point. | | `muster.updated` | The headcount has changed. It is checked every 5 seconds and only emitted when it changes. | | `muster.worker_missing` | Someone is missing. | | `muster.ended` | The evacuation has ended. | When you create a destination you can subscribe to whole groups (`alert.*`, `muster.*`) or to specific types. ## The envelope Every event has the same shape: | Field | What it is | | --- | --- | | `id` | Event identifier, for deduplication. | | `type` | One of the types above. | | `api_version` | `"2026-09-24"`. | | `created_at` | When it happened. | | `organization_id` | The site’s company. `null` in test events. | | `site_id` | The site. | | `data` | `{ "alert": … }` in `alert.*` events and `{ "muster": … }` in `muster.*` events. | What goes inside `data` is **the complete object exactly as the `GET` would return it at that moment**: the whole alert, or the evacuation with its headcount and its muster points. It isn’t a “something changed, come and look” notice; it’s the state. ```json { "id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "type": "alert.created", "api_version": "2026-09-24", "created_at": "2026-09-24T11:04:12Z", "organization_id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "site_id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "data": { "alert": { "id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "type": "sos", "severity": "critical", "status": "unacknowledged", "title": "SOS activado — Ana Ejemplo", "date_created": "2026-09-24T11:04:12Z", "date_last_modified": "2026-09-24T11:04:12Z", "site": { "id": "11111111-1111-1111-1111-111111111111", "name": "Planta Ejemplo — simulación" }, "zone": null, "worker": { "id": "33333333-3333-3333-3333-333333333305", "external_id": "EMP-004512", "name": "Ana Ejemplo", "contractor": "Contrata Ejemplo A" }, "device": { "id": "44444444-4444-4444-4444-444444444405", "serial": "OS-W-0005", "battery": 64, "date_last_seen": "2026-09-24T11:04:10Z" }, "location": { "latitude": 40.00121, "longitude": -3.00214, "uncertainty_m": 8, "source": "gps", "masked": false, "position_date_utc": "2026-09-24T11:04:08Z", "height": null }, "location_withheld": false, "details": { "trigger": "watch_button" }, "nearby": [ { "kind": "vehicle", "worker": { "id": "33333333-3333-3333-3333-333333333309", "external_id": "EMP-002210", "name": "Carlos Ejemplo", "contractor": "Contrata Ejemplo B" }, "vehicle_type": "Grúa móvil", "distance_m": 18, "reported_at": "2026-09-24T11:04:02Z" }, { "kind": "person", "worker": { "id": "33333333-3333-3333-3333-333333333301", "external_id": "EMP-000981", "name": "Diego Ejemplo", "contractor": null }, "vehicle_type": null, "distance_m": 26, "reported_at": "2026-09-24T11:03:58Z" } ], "vehicle_warnings": [], "worker_acknowledged_at": null, "acknowledged": null, "resolved": null, "assigned_to": null, "simulated": false, "panel_url": "https://app.safeonuba.com/alertas/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90" } } } ``` The full schema is in the reference: [`Event`](https://safeonuba.com/en/developers/reference/schemas#Event). ## Catching up on what you missed If your receiver has been down, or you want to check you aren’t missing anything, `GET /v1/events` returns the events from the last 30 days **in publication order**, oldest first. They are exactly the same bytes that were sent by webhook, so you process them with the same code. | Parameter | What it is | | --- | --- | | `since` | **Required.** From when, at most 30 days back. | | `type` | A single event type only. | | `limit`, `cursor` | Pagination (see [Pagination](https://safeonuba.com/en/developers/pagination)). | Any scope will do. ```javascript // Catches up from the last event you processed. async function catchUp(since) { let cursor = null; do { const url = new URL('https://sandbox.api.safeonuba.com/v1/events'); url.searchParams.set('since', since); url.searchParams.set('limit', '500'); if (cursor) url.searchParams.set('cursor', cursor); const res = await fetch(url, { headers: { Authorization: `Bearer ${await safeOnubaToken()}` } }); if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`); const page = await res.json(); for (const event of page.data) await handleEvent(event); // the same code as the webhook; deduplicate by id cursor = page.next_cursor; } while (cursor); } ``` ```python def catch_up(since: str) -> None: """Catches up from the last event you processed.""" cursor = None while True: params = {"since": since, "limit": 500} if cursor: params["cursor"] = cursor res = requests.get( "https://sandbox.api.safeonuba.com/v1/events", headers={"Authorization": f"Bearer {safeonuba_token()}"}, params=params, timeout=30, ) res.raise_for_status() page = res.json() for event in page["data"]: handle_event(event) # the same code as the webhook; deduplicate by id cursor = page["next_cursor"] if not cursor: break ``` ```csharp async Task CatchUp(string since) { string? cursor = null; do { var url = $"https://sandbox.api.safeonuba.com/v1/events?since={Uri.EscapeDataString(since)}&limit=500" + (cursor is null ? "" : $"&cursor={Uri.EscapeDataString(cursor)}"); var res = await http.GetAsync(url); res.EnsureSuccessStatusCode(); var page = await res.Content.ReadFromJsonAsync(); foreach (var ev in page.GetProperty("data").EnumerateArray()) await HandleEvent(ev); // the same code as the webhook; deduplicate by id var next = page.GetProperty("next_cursor"); cursor = next.ValueKind == JsonValueKind.Null ? null : next.GetString(); } while (cursor is not null); } ``` ### When to use it - **When your receiver starts up** after downtime, from the `created_at` of the last event you processed. - **After a failed-delivery notice**: if a delivery runs out of retries, we email your technical contact. The event is still in `/v1/events`. - **To fill gaps** if you notice you’re missing an intermediate step. Since it’s the same “at least once” delivery, deduplicate by `id`: most likely some of what you recover you had already received. > **Don’t use it instead of webhooks** > > Polling `/v1/events` in a loop arrives later than a webhook and uses up your [rate limits](https://safeonuba.com/en/developers/rate-limits). It’s the safety net, not the path. --- # Sandbox: integrating without watches > Run SOS, fall, heat stress or full evacuation scenarios with simulated watches and receive exactly what would arrive from a real plant. Page: https://safeonuba.com/en/developers/sandbox · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. The sandbox (`https://sandbox.api.safeonuba.com`) is a complete environment with **simulated watches**. It’s for integrating without having watches, without being at the plant and without bothering anyone. A scenario doesn’t invent alerts: the simulated watches at a sandbox site send their facts and the usual engine decides. What reaches you —webhook, `GET`, headcount— is the same as would come from a real watch, with one difference: everything carries `simulated: true`. ## Launching a scenario `POST /v1/sandbox/scenarios`, with any valid token: | Field | Required | What it is | | --- | --- | --- | | `scenario` | Yes | `sos`, `fall`, `heat_stress` or `evacuation`. | | `site_id` | No | The sandbox site. Without it, the first of yours that is a sandbox site. | ```bash curl -X POST https://sandbox.api.safeonuba.com/v1/sandbox/scenarios \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "scenario": "evacuation" }' ``` ```javascript const res = await fetch('https://sandbox.api.safeonuba.com/v1/sandbox/scenarios', { method: 'POST', headers: { Authorization: `Bearer ${await safeOnubaToken()}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ scenario: 'evacuation' }), }); const run = await res.json(); // { run_id, scenario, site_id, resource, ends_at } ``` ```python res = requests.post( "https://sandbox.api.safeonuba.com/v1/sandbox/scenarios", headers={"Authorization": f"Bearer {safeonuba_token()}"}, json={"scenario": "evacuation"}, timeout=10, ) res.raise_for_status() run = res.json() # run_id, scenario, site_id, resource, ends_at ``` ```csharp var res = await http.PostAsync("https://sandbox.api.safeonuba.com/v1/sandbox/scenarios", JsonContent.Create(new { scenario = "evacuation" })); res.EnsureSuccessStatusCode(); var run = await res.Content.ReadFromJsonAsync(); // run_id, scenario, site_id, resource, ends_at ``` The response (`202`) says what it has opened (`resource`: an alert or an evacuation, with its `id`) and when it closes by itself (`ends_at`). Query it with its `GET` or wait for the webhook. ## Alert scenarios | Scenario | What happens | | --- | --- | | `sos` | A test person presses SOS. | | `fall` | A test person falls. | | `heat_stress` | A test person suffers heat stress. | The alert appears (`alert.created`). You can acknowledge and close it from your control room with `PUT /v1/alerts/{id}`; if nobody closes it first, **it closes itself after 10 minutes** (`alert.resolved`). ## A complete evacuation `evacuation` is a general **3-minute** evacuation that runs through the whole cycle, designed for testing your control room’s banner from start to finish: | Moment | What happens | What reaches you | | --- | --- | --- | | 0 s | The evacuation starts. | `muster.started` | | 15 s | Four people reach point A. | `muster.updated` | | 35 s | Someone reaches point B and the control room rules it out. | `muster.targets_changed` | | 55 s | A person asks for help. | `alert.created` of type `evacuation_help` | | 95 s | The person who was at B goes to C. | `muster.updated` | | 3 min | All clear. | `muster.ended` | In between, a `muster.updated` arrives every time the headcount changes. With this you can check what is hardest to test at a real plant: that your control room shows a ruled-out point correctly, that the person asking for help moves to the top of “who’s missing”, and that the banner closes when it ends. ## Sandbox limits - At most **5 scenarios open at once** per client. - **One evacuation per site** at a time. - Going over either gives `409`. - In production, `POST /v1/sandbox/scenarios` responds `404`: it only exists in the sandbox. - A site that isn’t a sandbox site, or that has a real watch, responds `422` and runs nothing. ## Telling what is simulated Everything that comes out of a scenario carries `simulated: true`: the alerts, and whatever is generated from them. Events from `POST /v1/webhooks/{id}/test` also carry `organization_id: null`. If your control room shares a database or screens with production, filter on `simulated` so that a test is never mistaken for a real alert. --- # Rate limits > Requests and query time per minute, what happens when you go over, the headers that count them and why webhooks beat polling in a loop. Page: https://safeonuba.com/en/developers/rate-limits · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. Each client has two limits per minute. Both exist so that an integration that queries too much doesn’t take capacity away from anyone’s alerts, yours included. ## The two limits | Limit | Default | | --- | --- | | Requests per minute | 600 | | Query time per minute | 30 seconds | The second is less usual. Each request spends the time it takes to resolve, so **a long list counts for more than a single alert**: asking for 500 alerts from a month uses more of that budget than asking for one by its `id`. If your integration often asks for large lists, paginate with lower `limit` values or, better, stop polling and use webhooks. Both can be raised by contract. The token endpoint (`POST /v1/oauth/token`) also has its own limit, per IP. Request a token once an hour and reuse it (see [Renewing it](https://safeonuba.com/en/developers/authentication#renewing-it)). ## Headers Responses carry how much headroom you have left: | Header | What it says | | --- | --- | | `X-RateLimit-Limit` | Your limit. | | `X-RateLimit-Remaining` | What you have left in the current window. | ## When you go over The API responds `429` in `application/problem+json`, with the `Retry-After` header: the seconds you have to wait before trying again. ```javascript async function request(url, options = {}, attempts = 3) { const res = await fetch(url, options); if (res.status === 429 && attempts > 0) { const wait = Number(res.headers.get('Retry-After') ?? 1); await new Promise((r) => setTimeout(r, wait * 1000)); return request(url, options, attempts - 1); } return res; } ``` ```python import time import requests def request(method: str, url: str, attempts: int = 3, **kwargs) -> requests.Response: res = requests.request(method, url, timeout=10, **kwargs) if res.status_code == 429 and attempts > 0: time.sleep(float(res.headers.get("Retry-After", "1"))) return request(method, url, attempts - 1, **kwargs) return res ``` ```csharp async Task SendAsync(Func> send, int attempts = 3) { var res = await send(); if ((int)res.StatusCode == 429 && attempts > 0) { var wait = res.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1); await Task.Delay(wait); return await SendAsync(send, attempts - 1); } return res; } ``` Always respect `Retry-After`. Retrying earlier only lengthens the wait. ## Webhooks instead of polling in a loop The most common cause of a `429` is asking every few seconds “are there any new alerts?”. It’s what webhooks do best: every alert reaches you in a second or two, without using a single request. - To find out what’s happening: [webhooks](https://safeonuba.com/en/developers/webhooks). - To catch up after an outage: [`GET /v1/events`](https://safeonuba.com/en/developers/events#catching-up-on-what-you-missed). - To load the state at start-up or look for something specific: the lists, paginated. --- # Errors > Errors arrive as application/problem+json (RFC 9457) with the real HTTP status and a stable code to program against. Page: https://safeonuba.com/en/developers/errors · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. Errors arrive with the **real HTTP status** and a body in `application/problem+json`, the format of [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457). The exception is the token endpoint, which follows the OAuth2 format (see [Token errors](https://safeonuba.com/en/developers/authentication#token-errors)). ## The body | Field | What it is | | --- | --- | | `type` | URI that identifies the type of problem. | | `title` | Human-readable summary. | | `status` | The HTTP status, repeated. | | `detail` | Explanation of this specific case. | | `instance` | The path that failed. | | `code` | **Stable error code, to program against.** The text of `title` and `detail` may change; `code` won’t. | ```json { "type": "https://api.safeonuba.com/problems/insufficient-scope", "title": "Falta un permiso del token", "status": 403, "detail": "Esta petición necesita el scope alerts:write.", "instance": "/v1/alerts/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "code": "insufficient_scope" } ``` Program against `status` and `code`, never against the text. ## Codes | Code | When | What to do | | --- | --- | --- | | `401` | The token is missing, has expired or has been revoked. | Request a new token and retry once. | | `403` | The token lacks the scope the route asks for, or the site isn’t your client’s. | Request the token with that scope; if your client doesn’t have it, ask for it. Don’t retry. | | `404` | It doesn’t exist, or your identity can’t see it (another site, another contractor). | Don’t retry. | | `409` | The transition isn’t possible: for example, acknowledging or closing an alert that is already closed. In the sandbox, also too many open scenarios. | Read the resource again: it’s probably already in the state you wanted. | | `422` | The request doesn’t meet the contract: a missing field, a value out of range, a webhook URL that isn’t `https`. | Fix the request. `detail` says what fails. | | `429` | You’ve gone over a rate limit. | Wait as long as `Retry-After` says (see [Rate limits](https://safeonuba.com/en/developers/rate-limits)). | > **A 404 doesn’t always mean “it doesn’t exist”** > > The API sees the same as a dashboard user with those permissions. An alert from another site, or from a contractor your identity doesn’t cover, responds `404` just like one that doesn’t exist. It doesn’t reveal that it exists. ## To retry or not - `401`: yes, once, with a new token. - `429`: yes, after `Retry-After`. - Network errors and `5xx`: yes, with increasing waits. Reads can be repeated without any problem. Before repeating a `PUT /v1/alerts/{id}`, read the alert again: the first one may well have got through. - `403`, `404`, `409` and `422`: no. Repeating the same request will give the same result. --- # Pagination > Lists are paged with limit and an opaque cursor: the response includes next_cursor, which you pass back as cursor. Page: https://safeonuba.com/en/developers/pagination · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. The API’s lists (`/v1/alerts`, `/v1/musters`, `/v1/workers`, `/v1/devices`, `/v1/events`) are walked with a **cursor**. ## How it works | Parameter | What it is | | --- | --- | | `limit` | Items per page, from 1 to 500. 100 by default. | | `cursor` | The `next_cursor` from the previous page. On the first page, it isn’t sent. | Each response carries the page in `data` and a `next_cursor`: ```json { "data": [ … ], "next_cursor": "eyJ0IjoiMjAyNi0wOS0yNFQxMTowNDoxMloifQ" } ``` For the next page, repeat the same request with the same filters and `cursor=`. When `next_cursor` is `null`, there are no more. > **The cursor is opaque** > > Don’t interpret it or build it: pass it exactly as it is. ## Walking a whole list ```javascript async function* fetchAll(path, filters = {}) { let cursor = null; do { const url = new URL(`https://sandbox.api.safeonuba.com${path}`); for (const [k, v] of Object.entries(filters)) url.searchParams.set(k, v); url.searchParams.set('limit', '100'); if (cursor) url.searchParams.set('cursor', cursor); const res = await fetch(url, { headers: { Authorization: `Bearer ${await safeOnubaToken()}` } }); if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`); const page = await res.json(); yield* page.data; cursor = page.next_cursor; } while (cursor); } for await (const alert of fetchAll('/v1/alerts', { status: 'unacknowledged' })) { console.log(alert.id, alert.title); } ``` ```python def fetch_all(path: str, **filters): cursor = None while True: params = {**filters, "limit": 100} if cursor: params["cursor"] = cursor res = requests.get( f"https://sandbox.api.safeonuba.com{path}", headers={"Authorization": f"Bearer {safeonuba_token()}"}, params=params, timeout=30, ) res.raise_for_status() page = res.json() yield from page["data"] cursor = page["next_cursor"] if not cursor: return for alert in fetch_all("/v1/alerts", status="unacknowledged"): print(alert["id"], alert["title"]) ``` ```csharp async IAsyncEnumerable FetchAll(string path, string filters = "") { string? cursor = null; do { var url = $"https://sandbox.api.safeonuba.com{path}?limit=100{filters}" + (cursor is null ? "" : $"&cursor={Uri.EscapeDataString(cursor)}"); var res = await http.GetAsync(url); res.EnsureSuccessStatusCode(); var page = await res.Content.ReadFromJsonAsync(); foreach (var element in page.GetProperty("data").EnumerateArray()) yield return element; var next = page.GetProperty("next_cursor"); cursor = next.ValueKind == JsonValueKind.Null ? null : next.GetString(); } while (cursor is not null); } await foreach (var alert in FetchAll("/v1/alerts", "&status=unacknowledged")) Console.WriteLine(alert.GetProperty("title").GetString()); ``` ## Order of each list - `/v1/alerts`: from the most recent to the oldest. - `/v1/events`: from the oldest to the most recent, in publication order. That way it replays in the same order in which it was sent. ## What a page costs A page of 500 items counts for more towards your query-time limit than one of 50 (see [Rate limits](https://safeonuba.com/en/developers/rate-limits)). Ask for what you’re going to use. The short lists that aren’t paginated (`/v1/sites`, `/v1/sites/{id}/zones`, `/v1/musters/{id}/missing`, `/v1/positions/latest`, `/v1/webhooks`) return everything in `data`, with no `next_cursor`. --- # Versioning > What v1 in the path and api_version in each event mean, and the current state of the contract. Page: https://safeonuba.com/en/developers/versioning · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. The API has two version marks, and each says something different. ## v1 in the path All paths are under `/v1`. It is the major version of the contract: the paths, the objects and what they mean. ## api_version in each event Each event carries `api_version: "2026-09-24"`, and the webhooks’ `User-Agent` repeats it (`SafeOnuba-Webhooks/2026-09-24`). It is the date of the version of the format that event was generated with. Store it alongside each event you receive. If formats ever coexist, it is what will tell you which one each was written with. ## Current state: preview > **v1 · preview** > > The v1 contract is awaiting sign-off from the first customer and **may change**. Any change will be noted in the [changelog](https://safeonuba.com/en/developers/changelog), and the published contract always prevails. While the preview lasts: - Generate your client, if you do, from the contract ([OpenAPI 3.1](https://safeonuba.com/en/developers/openapi.json)) and regenerate it when it changes. - Ignore fields you don’t recognise instead of failing. It is the simplest way to stop a new field breaking your integration. - Program against the `code` of errors and against enum values, not against texts. ## The contract The OpenAPI 3.1 contract is the source of truth: the [reference](https://safeonuba.com/en/developers/reference) in this documentation is generated from it on every deployment. You can download it at [/en/developers/openapi.json](https://safeonuba.com/en/developers/openapi.json), and the API serves it at `GET /v1/openapi.json`, without a token. --- # Privacy and security > The API sees the same as a dashboard user with those permissions. Positions only when the plant allows it, never vital signs, and every request is logged. Page: https://safeonuba.com/en/developers/privacy-and-security · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. This page is for the technical team doing the integration, and also for the works council, the workers’ representatives or the data protection officer who needs to know what leaves the plant and where it goes. Everything said here is enforced by design in the API itself; it doesn’t depend on the integrator behaving well. ## The API sees the same as the dashboard, no more and no less An integration is not a back door. The API sees exactly what a SafeOnuba dashboard user with those same permissions would see: - the same plant privacy level; - the same privacy zones; - the same contractor scope: what that identity can’t see doesn’t exist as far as the API is concerned (it responds `404`). And scopes limit it further: a token without `positions:read` sees no positions, even if the plant allowed them to be seen. ## Positions: only when the plant allows it Each plant chooses its privacy level. The default is **“emergencies only”**: a person’s position can only be seen when they have an open alert above a certain severity, during an evacuation, or with a request approved in the dashboard. When the position exists but privacy doesn’t let you see it, the alert still arrives, without the position and with `location_withheld: true`. It isn’t visible in the dashboard either. Every position the API delivers with an alert behind it is recorded on that alert, under “Who has seen their position”. Technical detail: [Positions](https://safeonuba.com/en/developers/concepts/positions). ## Privacy zones In privacy zones —changing rooms, canteens— **the position is not recorded**. The API gives the name and type of those zones, but not their polygon. And anyone who was in one of them isn’t counted in `nearby`, the list of who was close to an incident. ## There are never any vital signs The API **doesn’t deliver vital signs**, neither a person’s nor aggregated. No field carries heart rate, body temperature or anything derived from them. A heat stress alert says what the worker has been told on their watch (stop and rest), the plant’s heat index and the minutes in the sun. Nothing that comes from their body. ## Optional names Workers’ names are optional by contract. Without them, each person is identified only by the code their company already uses (`external_id`), and alert titles are composed without a name. ## Who has access, and why, in plain view - **We register each API client ourselves**, with a reason that is recorded. - **Every request is logged.** - **The plant sees it in its dashboard**, under Settings → Integrations: who has access, to what and for what reason. - **Actions from the customer’s control room carry a name.** Acknowledging or closing an alert through the API requires the operator doing it, and the alert’s timeline shows it as “Customer · Operator (via integration)”. ## What the API doesn’t do - **It doesn’t start evacuations.** The system can propose them; a person always decides in the dashboard. - **It can’t be used from a browser.** It has no CORS: calls go out from the customer’s server, which is where the credentials live. ## Transport and credential security - **All traffic is encrypted with TLS**, webhooks included: a destination can only be an `https` URL at a public address, and redirects are not followed. - **Tokens last one hour** and are revoked instantly if the client is deregistered. - **Secrets are shown only once**, both the `client_secret` and each webhook’s signing secret, and both can be rotated without downtime. - **Every webhook is signed** with HMAC-SHA256 and a timestamp, and the receiver rejects anything that doesn’t match or is more than 5 minutes old (see [Verifying a webhook signature](https://safeonuba.com/en/developers/webhooks/verify-signature)). ## Questions a committee usually asks **Can the company track a worker through the API?** Only if the plant has chosen a privacy level in the dashboard that allows it, and with the same limits. With “emergencies only”, which is the default, the API doesn’t see the position of anyone who doesn’t have an open emergency. **Can the integration tell how someone’s health is?** No. There are no vital signs in the API, neither individual nor aggregated. **How do we know who is connected?** In the dashboard, Settings → Integrations: who, to what and for what reason. And every request is logged. **What if the integration acknowledges or closes an alert?** It goes in the alert’s timeline with the name of the operator who did it and the “via integration” mark. --- # Changelog > Changes to the API contract, newest first. Page: https://safeonuba.com/en/developers/changelog · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. Changes to the API contract, newest first. While v1 is in preview, a change may not be compatible with the previous one: here we will say when it is and when it isn’t. ## 2026-09-24 · v1 in preview First published version of the contract (`api_version: "2026-09-24"`), awaiting sign-off from the first customer. - OAuth2 `client_credentials` authentication and six scopes. - Sites, zones and muster points. - Alerts: query, and acknowledge or close from the customer’s control room. - Evacuations: state, headcount, ruled-out points and who is missing. - Workers, watches and latest visible positions. - Webhooks signed with HMAC-SHA256, with retries for 24 hours and secret rotation without downtime. - Replay of events from the last 30 days with `GET /v1/events`. - Sandbox with SOS, fall, heat stress and full evacuation scenarios. --- # Frequently asked questions > How to request access, who to contact for support and the questions that usually come up when integrating. Page: https://safeonuba.com/en/developers/faq · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. ## Access and support ### How do I request access to the API? Write to us at [info@safeonuba.com](mailto:info@safeonuba.com?subject=SafeOnuba%20API%20access) with your company, the plant and what you want to integrate. We register access ourselves, customer by customer, with a reason that is recorded and that the plant sees in its dashboard. ### Can I try it before I have production access? Yes, and it is the recommended way: you integrate in the [sandbox](https://safeonuba.com/en/developers/sandbox), with simulated watches. Production is activated when the customer is registered. ### Is the production API open yet? It is not yet open to the public. `https://api.safeonuba.com` is activated when each customer is registered. ### Who do I write to if something doesn’t work? To [info@safeonuba.com](mailto:info@safeonuba.com?subject=SafeOnuba%20API%20support). If it is an API error, include the `code` and the `instance` from the error body, the time (UTC) and, if it is a webhook, the `X-SafeOnuba-Event-Id`. **Never send the `client_secret`, a token or a webhook secret.** ## Integration ### Can I call the API from a browser or from an intranet page? Not directly. The API has no CORS, and the `client_secret` must never be in a browser anyway. Put a service of your own in between: your server calls the API and your page talks to your server. ### Can I start an evacuation from my control room? Not in v1. The system proposes and a person decides in the SafeOnuba dashboard. Your control room receives the evacuation as soon as it starts. ### How long does an alert take to arrive? From the moment an SOS goes off until it reaches your receiver, it normally takes 1 or 2 seconds. That is a typical measurement, not a service commitment. ### The same event reached me twice. That is normal: delivery is “at least once”. Deduplicate by the event’s `id`. See [Duplicates](https://safeonuba.com/en/developers/webhooks#duplicates). ### Events reach me out of order. That is also normal: urgent ones go out first and retries arrive when they arrive. Keep the most recent one. See [Order](https://safeonuba.com/en/developers/webhooks#order). ### My receiver has been down. Have I lost events? No, if you recover them within 30 days: `GET /v1/events` returns the same events, byte for byte. See [Catching up on what you missed](https://safeonuba.com/en/developers/events#catching-up-on-what-you-missed). ### The signature doesn’t match. Almost always it is being calculated over the JSON after it has been parsed and re-serialised, and not over the raw body. See [Verifying a webhook signature](https://safeonuba.com/en/developers/webhooks/verify-signature#common-mistakes). ### An alert arrives without a position. That is the plant’s privacy level: the alert is below the severity at which the position can be seen, and it carries `location_withheld: true`. See [Positions](https://safeonuba.com/en/developers/concepts/positions). ### Why can’t I see the workers’ names? Names are optional by contract. Without them, each person is identified by their `external_id`. See [Workers and watches](https://safeonuba.com/en/developers/concepts/workers-and-watches#no-names). ### Can I use an AI assistant to integrate? Yes. Every page has the **Copy** button, at the top right, which copies it as Markdown, and its text version by adding `.md` to the address. To give it everything at once, [/en/llms-full.txt](https://safeonuba.com/en/llms-full.txt). See [Using this documentation with AI](https://safeonuba.com/en/developers#using-this-documentation-with-ai). ### Can SafeOnuba receive alarms from my systems? There is an input for third-party alarms, but it is configured on request and is not part of this public API. Write to us if you need it. --- # API reference > Every operation, parameter, response and schema of API v1, generated from the OpenAPI 3.1 contract. Page: https://safeonuba.com/en/developers/reference · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. ## Environments | Environment | Address | | --- | --- | | Production | `https://api.safeonuba.com` | | Testing, with simulated watches | `https://sandbox.api.safeonuba.com` | OpenAPI 3.1 contract: https://safeonuba.com/en/developers/openapi.json ## [Authentication](https://safeonuba.com/en/developers/reference/authentication.md) OAuth2 `client_credentials`: an opaque token valid for one hour. - `POST /v1/oauth/token`: Request a token (client_credentials) ## [Sites](https://safeonuba.com/en/developers/reference/sites.md) The client’s sites and their zones. - `GET /v1/sites`: The client’s sites - `GET /v1/sites/{id}/zones`: A site’s zones and muster points ## [Alerts](https://safeonuba.com/en/developers/reference/alerts.md) What has happened, and acknowledging or resolving it from the customer’s control room. - `GET /v1/alerts`: List alerts - `GET /v1/alerts/{id}`: Alert detail - `PUT /v1/alerts/{id}`: Acknowledge or resolve an alert ## [Evacuations](https://safeonuba.com/en/developers/reference/evacuations.md) Status, headcount by status and by muster point, ruled-out points and who is missing. - `GET /v1/musters`: List evacuations - `GET /v1/musters/{id}`: Status and headcount of an evacuation - `GET /v1/musters/{id}/missing`: Who is missing ## [Workers](https://safeonuba.com/en/developers/reference/workers.md) Workers and watches. - `GET /v1/workers`: List workers - `GET /v1/workers/{id}`: Worker detail - `GET /v1/devices`: List watches ## [Positions](https://safeonuba.com/en/developers/reference/positions.md) Only those the privacy veil allows. - `GET /v1/positions/latest`: Latest visible positions ## [Events](https://safeonuba.com/en/developers/reference/events.md) Replay the events of the last 30 days. - `GET /v1/events`: Replay events ## [Webhooks](https://safeonuba.com/en/developers/reference/webhooks.md) Destinations and the format of what is sent to them. - `GET /v1/webhooks`: List destinations - `POST /v1/webhooks`: Create a destination - `DELETE /v1/webhooks/{id}`: Delete a destination - `POST /v1/webhooks/{id}/rotate-secret`: Rotate the signing secret - `POST /v1/webhooks/{id}/test`: Send a test event ## [Sandbox](https://safeonuba.com/en/developers/reference/sandbox.md) Test scenarios with simulated watches, so you can integrate without watches. Only on `sandbox.api.safeonuba.com`. - `POST /v1/sandbox/scenarios`: Launch a test scenario ## [Schemas](https://safeonuba.com/en/developers/reference/schemas.md) --- # Authentication · Reference > OAuth2 `client_credentials`: an opaque token valid for one hour. Page: https://safeonuba.com/en/developers/reference/authentication · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. ## Request a token (client_credentials) `POST /v1/oauth/token` · no token · [see it on the web](https://safeonuba.com/en/developers/reference/authentication#createToken) OAuth2 `client_credentials`. The token is opaque, lasts one hour and is revoked immediately when the client is deactivated. ### Body (`application/x-www-form-urlencoded`, schema `TokenRequest`) - `grant_type` (string, required) Values: `client_credentials`. - `client_id` (string, required) - `client_secret` (string, required) - `scope` (string): Space-separated; a subset of the client’s. Without it, all of the client’s. ### Responses - `200`: Token issued. → `TokenResponse` (`application/json`) - `400`: Malformed request or scope not allowed. → `OAuthError` (`application/json`) - `401`: Incorrect client or secret. → `OAuthError` (`application/json`) ### Example request (sandbox) ```bash curl -X POST https://sandbox.api.safeonuba.com/v1/oauth/token \ -d grant_type=client_credentials \ -d client_id="$SAFEONUBA_CLIENT_ID" \ -d client_secret="$SAFEONUBA_CLIENT_SECRET" \ -d scope="alerts:read musters:read" ``` ### Example response (`200`) ```json { "access_token": "at_9c1e…", "token_type": "Bearer", "expires_in": 3600, "scope": "alerts:read alerts:write musters:read" } ``` --- # Sites · Reference > The client’s sites and their zones. Page: https://safeonuba.com/en/developers/reference/sites · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. ## The client’s sites `GET /v1/sites` · any token · [see it on the web](https://safeonuba.com/en/developers/reference/sites#listSites) ### Responses - `200`: Sites the client can access. → `SiteList` (`application/json`) - `401`: The token is missing, has expired or has been revoked. → `Problem` (`application/problem+json`) - `403`: The token lacks the scope the route requires, or the site doesn’t belong to the client. → `Problem` (`application/problem+json`) - `429`: Too many requests, or too much query time: each client has a number of requests per minute (600 by default) and a query time per minute (30 s by default), and each request spends however long it takes, so a long list counts for more than a single alert. `Retry-After` says when to retry. → `Problem` (`application/problem+json`) ### Example request (sandbox) ```bash curl "https://sandbox.api.safeonuba.com/v1/sites" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Example response (`200`) ```json { "data": [ { "id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "name": "Planta Ejemplo — simulación", "location": "Polígono industrial de ejemplo", "privacy_mode": "on_demand", "unveil_min_severity": "low" } ] } ``` ## A site’s zones and muster points `GET /v1/sites/{id}/zones` · scope `alerts:read` · [see it on the web](https://safeonuba.com/en/developers/reference/sites#listSiteZones) GeoJSON in WGS84. Privacy zones come with name and type but no geometry. ### Path parameters - `id` (string (uuid), required): Site id. ### Responses - `200`: Zones in force. → `ZoneList` (`application/json`) - `401`: The token is missing, has expired or has been revoked. → `Problem` (`application/problem+json`) - `403`: The token lacks the scope the route requires, or the site doesn’t belong to the client. → `Problem` (`application/problem+json`) - `404`: The site doesn’t exist or doesn’t belong to the client. → `Problem` (`application/problem+json`) - `429`: Too many requests, or too much query time: each client has a number of requests per minute (600 by default) and a query time per minute (30 s by default), and each request spends however long it takes, so a long list counts for more than a single alert. `Retry-After` says when to retry. → `Problem` (`application/problem+json`) ### Example request (sandbox) ```bash curl "https://sandbox.api.safeonuba.com/v1/sites/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90/zones" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Example response (`200`) ```json { "data": [ { "id": "f6040b41-be81-4bf3-af68-bfaf3e284c54", "name": "Punto de reunión PR-3 · Zona alta", "type": "assembly_point", "geometry": { "type": "Polygon", "coordinates": [ [ [ -2.9825, 40.0081 ], [ -2.9818, 40.0081 ], [ -2.9818, 40.0076 ], [ -2.9825, 40.0076 ], [ -2.9825, 40.0081 ] ] ] }, "assembly_point": { "serves": [ "general", "tsunami" ], "elevation_m": 50, "elevation_source": "verified", "vertical": false, "capacity": 200 } } ] } ``` --- # Alerts · Reference > What has happened, and acknowledging or resolving it from the customer’s control room. Page: https://safeonuba.com/en/developers/reference/alerts · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. ## List alerts `GET /v1/alerts` · scope `alerts:read` · [see it on the web](https://safeonuba.com/en/developers/reference/alerts#listAlerts) ### Query parameters - `site_id` (string (uuid)) - `type` (AlertType): What has happened. `earthquake` and `tsunami_risk` concern the whole plant: they carry no `worker` or `device`. A nearby vehicle is **not** an alert: it appears inside an incident’s alert (`nearby`, `vehicle_warnings`). Values: `sos`, `fall_detected`, `restricted_zone_entry`, `forbidden_zone_entry`, `permit_expired_inside`, `permit_closed_inside`, `heat_stress`, `evacuation_help`, `worker_call_request`, `device_offline`, `low_battery`, `earthquake`, `tsunami_risk`. - `status` (AlertStatus): `acknowledged`: an operator has taken charge. It is not the same as `worker_acknowledged_at`, which means the worker has seen the alert on their watch. Values: `unacknowledged`, `acknowledged`, `resolved`. - `severity` (Severity): Minimum severity. Values: `low`, `medium`, `high`, `critical`. - `since` (string (date-time)) - `until` (string (date-time)) - `limit` (integer): Items per page (maximum 500). (from 1 to 500, default 100) - `cursor` (string): The `next_cursor` from the previous page. ### Responses - `200`: Alerts, from the most recent to the oldest. → `AlertPage` (`application/json`) - `401`: The token is missing, has expired or has been revoked. → `Problem` (`application/problem+json`) - `403`: The token lacks the scope the route requires, or the site doesn’t belong to the client. → `Problem` (`application/problem+json`) - `429`: Too many requests, or too much query time: each client has a number of requests per minute (600 by default) and a query time per minute (30 s by default), and each request spends however long it takes, so a long list counts for more than a single alert. `Retry-After` says when to retry. → `Problem` (`application/problem+json`) ### Example request (sandbox) ```bash curl "https://sandbox.api.safeonuba.com/v1/alerts" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Example response (`200`) ```json { "data": [ { "id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "type": "sos", "severity": "critical", "status": "unacknowledged", "title": "SOS activado — Ana Ejemplo", "date_created": "2026-09-24T11:04:12Z", "date_last_modified": "2026-09-24T11:04:12Z", "site": { "id": "11111111-1111-1111-1111-111111111111", "name": "Planta Ejemplo — simulación" }, "zone": null, "worker": { "id": "33333333-3333-3333-3333-333333333305", "external_id": "EMP-004512", "name": "Ana Ejemplo", "contractor": "Contrata Ejemplo A" }, "device": { "id": "44444444-4444-4444-4444-444444444405", "serial": "OS-W-0005", "battery": 64, "date_last_seen": "2026-09-24T11:04:10Z" }, "location": { "latitude": 40.00121, "longitude": -3.00214, "uncertainty_m": 8, "source": "gps", "masked": false, "position_date_utc": "2026-09-24T11:04:08Z", "height": null }, "location_withheld": false, "details": { "trigger": "watch_button" }, "nearby": [ { "kind": "vehicle", "worker": { "id": "33333333-3333-3333-3333-333333333309", "external_id": "EMP-002210", "name": "Carlos Ejemplo", "contractor": "Contrata Ejemplo B" }, "vehicle_type": "Grúa móvil", "distance_m": 18, "reported_at": "2026-09-24T11:04:02Z" }, { "kind": "person", "worker": { "id": "33333333-3333-3333-3333-333333333301", "external_id": "EMP-000981", "name": "Diego Ejemplo", "contractor": null }, "vehicle_type": null, "distance_m": 26, "reported_at": "2026-09-24T11:03:58Z" } ], "vehicle_warnings": [], "worker_acknowledged_at": null, "acknowledged": null, "resolved": null, "assigned_to": null, "simulated": false, "panel_url": "https://app.safeonuba.com/alertas/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90" } ], "next_cursor": "eyJ0IjoiMjAyNi0wOS0yNFQxMTowNDoxMloifQ" } ``` ## Alert detail `GET /v1/alerts/{id}` · scope `alerts:read` · [see it on the web](https://safeonuba.com/en/developers/reference/alerts#getAlert) ### Path parameters - `id` (string (uuid), required): Alert id. ### Responses - `200`: The alert. → `Alert` (`application/json`) - `401`: The token is missing, has expired or has been revoked. → `Problem` (`application/problem+json`) - `403`: The token lacks the scope the route requires, or the site doesn’t belong to the client. → `Problem` (`application/problem+json`) - `404`: It doesn’t exist, or this identity can’t see it (another site, another contractor). → `Problem` (`application/problem+json`) - `429`: Too many requests, or too much query time: each client has a number of requests per minute (600 by default) and a query time per minute (30 s by default), and each request spends however long it takes, so a long list counts for more than a single alert. `Retry-After` says when to retry. → `Problem` (`application/problem+json`) ### Example request (sandbox) ```bash curl "https://sandbox.api.safeonuba.com/v1/alerts/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Example response (`200`) ```json { "id": "7a1e9c40-52d8-4b0f-a3c6-2e9d81f0b7a4", "type": "forbidden_zone_entry", "severity": "high", "status": "unacknowledged", "title": "Entrada en zona prohibida — Almacenamiento H₂ (ATEX) — EMP-003318", "date_created": "2026-09-24T10:12:40Z", "date_last_modified": "2026-09-24T10:12:40Z", "site": { "id": "11111111-1111-1111-1111-111111111111", "name": "Planta Ejemplo — simulación" }, "zone": { "id": "22222222-2222-2222-2222-222222222202", "name": "Almacenamiento H₂ (ATEX)" }, "worker": { "id": "33333333-3333-3333-3333-333333333303", "external_id": "EMP-003318", "contractor": "Contrata Ejemplo C" }, "device": { "id": "44444444-4444-4444-4444-444444444403", "serial": "OS-W-0003", "battery": 81, "date_last_seen": "2026-09-24T10:12:38Z" }, "location": { "latitude": 40.00344, "longitude": -3.00561, "uncertainty_m": 5, "source": "gps", "masked": false, "position_date_utc": "2026-09-24T10:12:36Z", "height": { "height_m": 7.6, "uncertainty_m": 1, "building": "Nave de proceso", "floor": "Planta 1" } }, "location_withheld": false, "details": { "zone_type": "forbidden", "permit_reference": null }, "nearby": [], "vehicle_warnings": [], "worker_acknowledged_at": "2026-09-24T10:12:51Z", "acknowledged": null, "resolved": null, "assigned_to": null, "simulated": true, "panel_url": "https://app.safeonuba.com/alertas/7a1e9c40-52d8-4b0f-a3c6-2e9d81f0b7a4" } ``` ## Acknowledge or resolve an alert `PUT /v1/alerts/{id}` · scope `alerts:write` · [see it on the web](https://safeonuba.com/en/developers/reference/alerts#updateAlert) The alert’s timeline reads “Client · Operator (via integration)”. Acknowledging is not resolving: an acknowledged alert keeps marking the person on the map until it is resolved. ### Path parameters - `id` (string (uuid), required): Alert id. ### Body (`application/json`, schema `AlertUpdate`) - `status` (string, required) Values: `acknowledged`, `resolved`. - `resolution_reason` (string): On resolving: why. Free text; it goes in the alert’s timeline next to the note. (up to 200 characters) - `note` (string) (up to 500 characters) - `actor` (Actor, required) - `name` (string, required) (up to 120 characters) - `external_id` (string) (up to 64 characters) ### Responses - `200`: The alert, now updated. → `Alert` (`application/json`) - `401`: The token is missing, has expired or has been revoked. → `Problem` (`application/problem+json`) - `403`: The token lacks the scope the route requires, or the site doesn’t belong to the client. → `Problem` (`application/problem+json`) - `404`: It doesn’t exist, or this identity can’t see it. → `Problem` (`application/problem+json`) - `409`: The transition isn’t possible (for example, acknowledging an alert that is already resolved). → `Problem` (`application/problem+json`) - `422`: The body is not valid. → `Problem` (`application/problem+json`) - `429`: Too many requests, or too much query time: each client has a number of requests per minute (600 by default) and a query time per minute (30 s by default), and each request spends however long it takes, so a long list counts for more than a single alert. `Retry-After` says when to retry. → `Problem` (`application/problem+json`) ### Example request (sandbox) ```bash curl -X PUT "https://sandbox.api.safeonuba.com/v1/alerts/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "status": "acknowledged", "actor": { "name": "E. Ejemplo" } }' ``` ### Example response (`200`) ```json { "id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "type": "sos", "severity": "critical", "status": "unacknowledged", "title": "SOS activado — Ana Ejemplo", "date_created": "2026-09-24T11:04:12Z", "date_last_modified": "2026-09-24T11:04:12Z", "site": { "id": "11111111-1111-1111-1111-111111111111", "name": "Planta Ejemplo — simulación" }, "zone": null, "worker": { "id": "33333333-3333-3333-3333-333333333305", "external_id": "EMP-004512", "name": "Ana Ejemplo", "contractor": "Contrata Ejemplo A" }, "device": { "id": "44444444-4444-4444-4444-444444444405", "serial": "OS-W-0005", "battery": 64, "date_last_seen": "2026-09-24T11:04:10Z" }, "location": { "latitude": 40.00121, "longitude": -3.00214, "uncertainty_m": 8, "source": "gps", "masked": false, "position_date_utc": "2026-09-24T11:04:08Z", "height": null }, "location_withheld": false, "details": { "trigger": "watch_button" }, "nearby": [ { "kind": "vehicle", "worker": { "id": "33333333-3333-3333-3333-333333333309", "external_id": "EMP-002210", "name": "Carlos Ejemplo", "contractor": "Contrata Ejemplo B" }, "vehicle_type": "Grúa móvil", "distance_m": 18, "reported_at": "2026-09-24T11:04:02Z" }, { "kind": "person", "worker": { "id": "33333333-3333-3333-3333-333333333301", "external_id": "EMP-000981", "name": "Diego Ejemplo", "contractor": null }, "vehicle_type": null, "distance_m": 26, "reported_at": "2026-09-24T11:03:58Z" } ], "vehicle_warnings": [], "worker_acknowledged_at": null, "acknowledged": null, "resolved": null, "assigned_to": null, "simulated": false, "panel_url": "https://app.safeonuba.com/alertas/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90" } ``` --- # Evacuations · Reference > Status, headcount by status and by muster point, ruled-out points and who is missing. Page: https://safeonuba.com/en/developers/reference/evacuations · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. ## List evacuations `GET /v1/musters` · scope `musters:read` · [see it on the web](https://safeonuba.com/en/developers/reference/evacuations#listMusters) ### Query parameters - `site_id` (string (uuid)) - `status` (string) Values: `active`, `completed`, `cancelled`. - `since` (string (date-time)) - `limit` (integer): Items per page (maximum 500). (from 1 to 500, default 100) - `cursor` (string): The `next_cursor` from the previous page. ### Responses - `200`: Active and past evacuations. → `MusterPage` (`application/json`) - `401`: The token is missing, has expired or has been revoked. → `Problem` (`application/problem+json`) - `403`: The token lacks the scope the route requires, or the site doesn’t belong to the client. → `Problem` (`application/problem+json`) - `429`: Too many requests, or too much query time: each client has a number of requests per minute (600 by default) and a query time per minute (30 s by default), and each request spends however long it takes, so a long list counts for more than a single alert. `Retry-After` says when to retry. → `Problem` (`application/problem+json`) ### Example request (sandbox) ```bash curl "https://sandbox.api.safeonuba.com/v1/musters" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Example response (`200`) ```json { "data": [ { "id": "4fb94227-7afb-442d-a672-6fd82069518f", "site": { "id": "11111111-1111-1111-1111-111111111111", "name": "Planta Ejemplo — simulación" }, "status": "active", "phase": "evacuating", "kind": "general", "reason": "Fuga en Mantenimiento", "started_at": "2026-09-24T18:31:06Z", "ended_at": null, "deadline": null, "origin": { "latitude": 39.97894, "longitude": -2.96877, "radius_m": 400, "label": "Mantenimiento — permiso PT-0453", "zone": { "id": "22222222-2222-2222-2222-222222222203", "name": "Mantenimiento — permiso PT-0453" } }, "targets_revision": 3, "totals": { "expected": 9, "safe": 6, "help": 0, "wrong_point": 0, "pending": 2, "no_signal": 0, "not_worn": 1 }, "assembly_points": [ { "id": "22222222-2222-2222-2222-222222222204", "name": "Punto de reunión PR-1", "status": "excluded", "count": 0, "excluded": { "reason": "A 371 m del origen (radio 400 m)", "recommended": true, "at": "2026-09-24T18:31:06Z", "by": "Elena Ejemplo" } }, { "id": "22222222-2222-2222-2222-222222222205", "name": "Punto de reunión PR-2", "status": "excluded", "count": 0, "excluded": { "reason": "A sotavento: viento del SO a 9 km/h", "recommended": true, "at": "2026-09-24T18:31:42Z", "by": "Elena Ejemplo" } }, { "id": "f6040b41-be81-4bf3-af68-bfaf3e284c54", "name": "Punto de reunión PR-3 · Zona alta", "status": "available", "count": 6, "excluded": null } ] } ], "next_cursor": "eyJ0IjoiMjAyNi0wOS0yNFQxMTowNDoxMloifQ" } ``` ## Status and headcount of an evacuation `GET /v1/musters/{id}` · scope `musters:read` · [see it on the web](https://safeonuba.com/en/developers/reference/evacuations#getMuster) The server counts the headcount with the same rule as the dashboard and the closing summary: an accurate position inside a valid point, or a declared arrival at one. ### Path parameters - `id` (string (uuid), required): Evacuation id. ### Responses - `200`: The evacuation. → `Muster` (`application/json`) - `401`: The token is missing, has expired or has been revoked. → `Problem` (`application/problem+json`) - `403`: The token lacks the scope the route requires, or the site doesn’t belong to the client. → `Problem` (`application/problem+json`) - `404`: It doesn’t exist or doesn’t belong to one of the client’s sites. → `Problem` (`application/problem+json`) - `429`: Too many requests, or too much query time: each client has a number of requests per minute (600 by default) and a query time per minute (30 s by default), and each request spends however long it takes, so a long list counts for more than a single alert. `Retry-After` says when to retry. → `Problem` (`application/problem+json`) ### Example request (sandbox) ```bash curl "https://sandbox.api.safeonuba.com/v1/musters/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Example response (`200`) ```json { "id": "4fb94227-7afb-442d-a672-6fd82069518f", "site": { "id": "11111111-1111-1111-1111-111111111111", "name": "Planta Ejemplo — simulación" }, "status": "active", "phase": "evacuating", "kind": "general", "reason": "Fuga en Mantenimiento", "started_at": "2026-09-24T18:31:06Z", "ended_at": null, "deadline": null, "origin": { "latitude": 39.97894, "longitude": -2.96877, "radius_m": 400, "label": "Mantenimiento — permiso PT-0453", "zone": { "id": "22222222-2222-2222-2222-222222222203", "name": "Mantenimiento — permiso PT-0453" } }, "targets_revision": 3, "totals": { "expected": 9, "safe": 6, "help": 0, "wrong_point": 0, "pending": 2, "no_signal": 0, "not_worn": 1 }, "assembly_points": [ { "id": "22222222-2222-2222-2222-222222222204", "name": "Punto de reunión PR-1", "status": "excluded", "count": 0, "excluded": { "reason": "A 371 m del origen (radio 400 m)", "recommended": true, "at": "2026-09-24T18:31:06Z", "by": "Elena Ejemplo" } }, { "id": "22222222-2222-2222-2222-222222222205", "name": "Punto de reunión PR-2", "status": "excluded", "count": 0, "excluded": { "reason": "A sotavento: viento del SO a 9 km/h", "recommended": true, "at": "2026-09-24T18:31:42Z", "by": "Elena Ejemplo" } }, { "id": "f6040b41-be81-4bf3-af68-bfaf3e284c54", "name": "Punto de reunión PR-3 · Zona alta", "status": "available", "count": 6, "excluded": null } ] } ``` ## Who is missing `GET /v1/musters/{id}/missing` · scope `musters:read` · [see it on the web](https://safeonuba.com/en/developers/reference/evacuations#listMissingWorkers) First anyone asking for help or needing rescue, then anyone at a point that is unusable, with no signal, not wearing the watch, and heading out. The last position only with the `positions:read` scope, and if the privacy veil allows it. ### Path parameters - `id` (string (uuid), required): Evacuation id. ### Responses - `200`: Who is missing, by urgency. → `MissingWorkerList` (`application/json`) - `401`: The token is missing, has expired or has been revoked. → `Problem` (`application/problem+json`) - `403`: The token lacks the scope the route requires, or the site doesn’t belong to the client. → `Problem` (`application/problem+json`) - `404`: It doesn’t exist or doesn’t belong to one of the client’s sites. → `Problem` (`application/problem+json`) - `429`: Too many requests, or too much query time: each client has a number of requests per minute (600 by default) and a query time per minute (30 s by default), and each request spends however long it takes, so a long list counts for more than a single alert. `Retry-After` says when to retry. → `Problem` (`application/problem+json`) ### Example request (sandbox) ```bash curl "https://sandbox.api.safeonuba.com/v1/musters/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90/missing" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Example response (`200`) ```json { "data": [ { "worker": { "id": "33333333-3333-3333-3333-333333333305", "external_id": "EMP-004512", "contractor": "Contrata Ejemplo A" }, "device": { "id": "44444444-4444-4444-4444-444444444405", "serial": "OS-W-0005", "battery": 61, "date_last_seen": "2026-09-24T18:33:40Z" }, "status": "wrong_point", "needs_rescue": false, "help_reason": null, "assembly_point": { "id": "22222222-2222-2222-2222-222222222205", "name": "Punto de reunión PR-2", "valid": false }, "declared_at": "2026-09-24T18:32:10Z", "last_seen_at": "2026-09-24T18:33:40Z", "location": null, "location_withheld": false } ] } ``` --- # Workers · Reference > Workers and watches. Page: https://safeonuba.com/en/developers/reference/workers · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. ## List workers `GET /v1/workers` · scope `workers:read` · [see it on the web](https://safeonuba.com/en/developers/reference/workers#listWorkers) ### Query parameters - `site_id` (string (uuid)) - `limit` (integer): Items per page (maximum 500). (from 1 to 500, default 100) - `cursor` (string): The `next_cursor` from the previous page. ### Responses - `200`: Workers. → `WorkerPage` (`application/json`) - `401`: The token is missing, has expired or has been revoked. → `Problem` (`application/problem+json`) - `403`: The token lacks the scope the route requires, or the site doesn’t belong to the client. → `Problem` (`application/problem+json`) - `429`: Too many requests, or too much query time: each client has a number of requests per minute (600 by default) and a query time per minute (30 s by default), and each request spends however long it takes, so a long list counts for more than a single alert. `Retry-After` says when to retry. → `Problem` (`application/problem+json`) ### Example request (sandbox) ```bash curl "https://sandbox.api.safeonuba.com/v1/workers" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Example response (`200`) ```json { "data": [ { "id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "external_id": "EMP-004512", "name": "Ana Ejemplo", "contractor": "Contrata Ejemplo A", "role": "Andamiero", "is_active": false, "device": { "id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "serial": "OS-W-0005", "battery": 64, "date_last_seen": "2026-09-24T11:04:12Z" }, "date_last_seen": "2026-09-24T11:04:12Z" } ], "next_cursor": "eyJ0IjoiMjAyNi0wOS0yNFQxMTowNDoxMloifQ" } ``` ## Worker detail `GET /v1/workers/{id}` · scope `workers:read` · [see it on the web](https://safeonuba.com/en/developers/reference/workers#getWorker) ### Path parameters - `id` (string (uuid), required): Worker id. ### Responses - `200`: The worker. → `Worker` (`application/json`) - `401`: The token is missing, has expired or has been revoked. → `Problem` (`application/problem+json`) - `403`: The token lacks the scope the route requires, or the site doesn’t belong to the client. → `Problem` (`application/problem+json`) - `404`: It doesn’t exist or this identity can’t see it. → `Problem` (`application/problem+json`) - `429`: Too many requests, or too much query time: each client has a number of requests per minute (600 by default) and a query time per minute (30 s by default), and each request spends however long it takes, so a long list counts for more than a single alert. `Retry-After` says when to retry. → `Problem` (`application/problem+json`) ### Example request (sandbox) ```bash curl "https://sandbox.api.safeonuba.com/v1/workers/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Example response (`200`) ```json { "id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "external_id": "EMP-004512", "name": "Ana Ejemplo", "contractor": "Contrata Ejemplo A", "role": "Andamiero", "is_active": false, "device": { "id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "serial": "OS-W-0005", "battery": 64, "date_last_seen": "2026-09-24T11:04:12Z" }, "date_last_seen": "2026-09-24T11:04:12Z" } ``` ## List watches `GET /v1/devices` · scope `workers:read` · [see it on the web](https://safeonuba.com/en/developers/reference/workers#listDevices) ### Query parameters - `site_id` (string (uuid)) - `limit` (integer): Items per page (maximum 500). (from 1 to 500, default 100) - `cursor` (string): The `next_cursor` from the previous page. ### Responses - `200`: Watches: battery, whether it is being worn, last connection. → `DevicePage` (`application/json`) - `401`: The token is missing, has expired or has been revoked. → `Problem` (`application/problem+json`) - `403`: The token lacks the scope the route requires, or the site doesn’t belong to the client. → `Problem` (`application/problem+json`) - `429`: Too many requests, or too much query time: each client has a number of requests per minute (600 by default) and a query time per minute (30 s by default), and each request spends however long it takes, so a long list counts for more than a single alert. `Retry-After` says when to retry. → `Problem` (`application/problem+json`) ### Example request (sandbox) ```bash curl "https://sandbox.api.safeonuba.com/v1/devices" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Example response (`200`) ```json { "data": [ { "id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "serial": "OS-W-0005", "battery": 64, "date_last_seen": "2026-09-24T11:04:12Z", "model": "Galaxy Watch6", "status": "active", "worn": true, "worker": { "id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "external_id": "EMP-004512", "name": "Ana Ejemplo", "contractor": "Contrata Ejemplo A" } } ], "next_cursor": "eyJ0IjoiMjAyNi0wOS0yNFQxMTowNDoxMloifQ" } ``` --- # Positions · Reference > Only those the privacy veil allows. Page: https://safeonuba.com/en/developers/reference/positions · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. ## Latest visible positions `GET /v1/positions/latest` · scope `positions:read` · [see it on the web](https://safeonuba.com/en/developers/reference/positions#listLatestPositions) Only those the privacy veil allows right now. Every position served with an alert behind it is recorded on the alert (“Who has seen their position”). ### Query parameters - `site_id` (string (uuid)) ### Responses - `200`: Positions. → `PositionList` (`application/json`) - `401`: The token is missing, has expired or has been revoked. → `Problem` (`application/problem+json`) - `403`: The token lacks the scope the route requires, or the site doesn’t belong to the client. → `Problem` (`application/problem+json`) - `429`: Too many requests, or too much query time: each client has a number of requests per minute (600 by default) and a query time per minute (30 s by default), and each request spends however long it takes, so a long list counts for more than a single alert. `Retry-After` says when to retry. → `Problem` (`application/problem+json`) ### Example request (sandbox) ```bash curl "https://sandbox.api.safeonuba.com/v1/positions/latest" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Example response (`200`) ```json { "data": [ { "device_id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "worker": { "id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "external_id": "EMP-004512", "name": "Ana Ejemplo", "contractor": "Contrata Ejemplo A" }, "location": { "latitude": 40.00121, "longitude": -3.00214, "uncertainty_m": 8, "source": "gps", "masked": false, "position_date_utc": "2026-09-24T11:04:12Z", "height": { "height_m": 7.6, "uncertainty_m": 1, "building": "Nave de proceso", "floor": "Planta 1" } }, "in_vehicle": false } ] } ``` --- # Events · Reference > Replay the events of the last 30 days. Page: https://safeonuba.com/en/developers/reference/events · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. ## Replay events `GET /v1/events` · any token · [see it on the web](https://safeonuba.com/en/developers/reference/events#listEvents) The same ones that arrive by webhook, kept for 30 days: to recover what was lost after an outage on your receiver. ### Query parameters - `since` (string (date-time), required): Since when (at most 30 days back). - `type` (EventType): `muster.updated`: the headcount has changed (checked every 5 s, only emitted if it changes). `muster.targets_changed`: the control room has ruled out or restored a muster point. Values: `alert.created`, `alert.acknowledged`, `alert.resolved`, `alert.reopened`, `alert.worker_acknowledged`, `muster.started`, `muster.kind_changed`, `muster.phase_changed`, `muster.targets_changed`, `muster.updated`, `muster.worker_missing`, `muster.ended`. - `limit` (integer): Items per page (maximum 500). (from 1 to 500, default 100) - `cursor` (string): The `next_cursor` from the previous page. ### Responses - `200`: Events, from the oldest to the most recent. → `EventPage` (`application/json`) - `401`: The token is missing, has expired or has been revoked. → `Problem` (`application/problem+json`) - `403`: The token lacks the scope the route requires, or the site doesn’t belong to the client. → `Problem` (`application/problem+json`) - `429`: Too many requests, or too much query time: each client has a number of requests per minute (600 by default) and a query time per minute (30 s by default), and each request spends however long it takes, so a long list counts for more than a single alert. `Retry-After` says when to retry. → `Problem` (`application/problem+json`) ### Example request (sandbox) ```bash curl "https://sandbox.api.safeonuba.com/v1/events?since=2026-09-24T11%3A04%3A12Z" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Example response (`200`) ```json { "data": [ { "id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "type": "alert.created", "api_version": "2026-09-24", "created_at": "2026-09-24T11:04:12Z", "organization_id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "site_id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "data": { "alert": { "id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "type": "sos", "severity": "critical", "status": "unacknowledged", "title": "SOS activado — Ana Ejemplo", "date_created": "2026-09-24T11:04:12Z", "date_last_modified": "2026-09-24T11:04:12Z", "site": { "id": "11111111-1111-1111-1111-111111111111", "name": "Planta Ejemplo — simulación" }, "zone": null, "worker": { "id": "33333333-3333-3333-3333-333333333305", "external_id": "EMP-004512", "name": "Ana Ejemplo", "contractor": "Contrata Ejemplo A" }, "device": { "id": "44444444-4444-4444-4444-444444444405", "serial": "OS-W-0005", "battery": 64, "date_last_seen": "2026-09-24T11:04:10Z" }, "location": { "latitude": 40.00121, "longitude": -3.00214, "uncertainty_m": 8, "source": "gps", "masked": false, "position_date_utc": "2026-09-24T11:04:08Z", "height": null }, "location_withheld": false, "details": { "trigger": "watch_button" }, "nearby": [ { "kind": "vehicle", "worker": { "id": "33333333-3333-3333-3333-333333333309", "external_id": "EMP-002210", "name": "Carlos Ejemplo", "contractor": "Contrata Ejemplo B" }, "vehicle_type": "Grúa móvil", "distance_m": 18, "reported_at": "2026-09-24T11:04:02Z" }, { "kind": "person", "worker": { "id": "33333333-3333-3333-3333-333333333301", "external_id": "EMP-000981", "name": "Diego Ejemplo", "contractor": null }, "vehicle_type": null, "distance_m": 26, "reported_at": "2026-09-24T11:03:58Z" } ], "vehicle_warnings": [], "worker_acknowledged_at": null, "acknowledged": null, "resolved": null, "assigned_to": null, "simulated": false, "panel_url": "https://app.safeonuba.com/alertas/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90" } } } ], "next_cursor": "eyJ0IjoiMjAyNi0wOS0yNFQxMTowNDoxMloifQ" } ``` --- # Webhooks · Reference > Destinations and the format of what is sent to them. Page: https://safeonuba.com/en/developers/reference/webhooks · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. ## List destinations `GET /v1/webhooks` · scope `webhooks:manage` · [see it on the web](https://safeonuba.com/en/developers/reference/webhooks#listWebhooks) ### Responses - `200`: Destinations. → `WebhookEndpointList` (`application/json`) - `401`: The token is missing, has expired or has been revoked. → `Problem` (`application/problem+json`) - `403`: The token lacks the scope the route requires, or the site doesn’t belong to the client. → `Problem` (`application/problem+json`) - `429`: Too many requests, or too much query time: each client has a number of requests per minute (600 by default) and a query time per minute (30 s by default), and each request spends however long it takes, so a long list counts for more than a single alert. `Retry-After` says when to retry. → `Problem` (`application/problem+json`) ### Example request (sandbox) ```bash curl "https://sandbox.api.safeonuba.com/v1/webhooks" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Example response (`200`) ```json { "data": [ { "id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "url": "string", "event_types": [ "alert.created" ], "filters": { "site_ids": [ "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90" ], "min_severity": "low", "contractors": [ "string" ] }, "is_active": false, "failing_since": "2026-09-24T11:04:12Z", "created_at": "2026-09-24T11:04:12Z" } ] } ``` ## Create a destination `POST /v1/webhooks` · scope `webhooks:manage` · [see it on the web](https://safeonuba.com/en/developers/reference/webhooks#createWebhook) ### Body (`application/json`, schema `WebhookEndpointCreate`) - `url` (string, required) - `event_types` (array, required) - `filters` (object) - `site_ids` (array) - `min_severity` (Severity) Values: `low`, `medium`, `high`, `critical`. - `contractors` (array) ### Responses - `201`: Destination created, with its signing secret (this once only). → `WebhookEndpointCreated` (`application/json`) - `401`: The token is missing, has expired or has been revoked. → `Problem` (`application/problem+json`) - `403`: The token lacks the scope the route requires, or the site doesn’t belong to the client. → `Problem` (`application/problem+json`) - `422`: The body is not valid (the URL must be https). → `Problem` (`application/problem+json`) - `429`: Too many requests, or too much query time: each client has a number of requests per minute (600 by default) and a query time per minute (30 s by default), and each request spends however long it takes, so a long list counts for more than a single alert. `Retry-After` says when to retry. → `Problem` (`application/problem+json`) ### Example request (sandbox) ```bash curl -X POST "https://sandbox.api.safeonuba.com/v1/webhooks" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "url": "https://sala.cliente.example/safeonuba", "event_types": [ "alert.*", "muster.*" ] }' ``` ### Example response (`201`) ```json { "id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "url": "string", "event_types": [ "alert.created" ], "filters": { "site_ids": [ "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90" ], "min_severity": "low", "contractors": [ "string" ] }, "is_active": false, "failing_since": "2026-09-24T11:04:12Z", "created_at": "2026-09-24T11:04:12Z", "secret": "whsec_6f1c0d9e2b7a4c3f8e5d1a0b9c8e7f6a" } ``` ## Delete a destination `DELETE /v1/webhooks/{id}` · scope `webhooks:manage` · [see it on the web](https://safeonuba.com/en/developers/reference/webhooks#deleteWebhook) ### Path parameters - `id` (string (uuid), required): Destination id. ### Responses - `204`: Deleted. - `401`: The token is missing, has expired or has been revoked. → `Problem` (`application/problem+json`) - `403`: The token lacks the scope the route requires, or the site doesn’t belong to the client. → `Problem` (`application/problem+json`) - `404`: It doesn’t exist. → `Problem` (`application/problem+json`) - `429`: Too many requests, or too much query time: each client has a number of requests per minute (600 by default) and a query time per minute (30 s by default), and each request spends however long it takes, so a long list counts for more than a single alert. `Retry-After` says when to retry. → `Problem` (`application/problem+json`) ### Example request (sandbox) ```bash curl -X DELETE "https://sandbox.api.safeonuba.com/v1/webhooks/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ## Rotate the signing secret `POST /v1/webhooks/{id}/rotate-secret` · scope `webhooks:manage` · [see it on the web](https://safeonuba.com/en/developers/reference/webhooks#rotateWebhookSecret) Issues a new secret. The previous one keeps signing for 24 h: during that time every delivery carries both signatures in `X-SafeOnuba-Signature`, separated by a comma, so the receiver can change secret without interruption. A second rotation retires the first: there are never more than two. ### Path parameters - `id` (string (uuid), required): Destination id. ### Responses - `200`: The new secret (this once only) and until when the previous one signs. → `WebhookSecretRotated` (`application/json`) - `401`: The token is missing, has expired or has been revoked. → `Problem` (`application/problem+json`) - `403`: The token lacks the scope the route requires, or the site doesn’t belong to the client. → `Problem` (`application/problem+json`) - `404`: It doesn’t exist. → `Problem` (`application/problem+json`) - `429`: Too many requests, or too much query time: each client has a number of requests per minute (600 by default) and a query time per minute (30 s by default), and each request spends however long it takes, so a long list counts for more than a single alert. `Retry-After` says when to retry. → `Problem` (`application/problem+json`) ### Example request (sandbox) ```bash curl -X POST "https://sandbox.api.safeonuba.com/v1/webhooks/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90/rotate-secret" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Example response (`200`) ```json { "secret": "whsec_6f1c0d9e2b7a4c3f8e5d1a0b9c8e7f6a", "previous_valid_until": "2026-09-24T11:04:12Z" } ``` ## Send a test event `POST /v1/webhooks/{id}/test` · scope `webhooks:manage` · [see it on the web](https://safeonuba.com/en/developers/reference/webhooks#testWebhook) Sends an `alert.created` marked `simulated: true`, signed like a real one. ### Path parameters - `id` (string (uuid), required): Destination id. ### Responses - `202`: Queued: the result shows on the destination (`failing_since`). - `401`: The token is missing, has expired or has been revoked. → `Problem` (`application/problem+json`) - `403`: The token lacks the scope the route requires, or the site doesn’t belong to the client. → `Problem` (`application/problem+json`) - `404`: It doesn’t exist. → `Problem` (`application/problem+json`) - `429`: Too many requests, or too much query time: each client has a number of requests per minute (600 by default) and a query time per minute (30 s by default), and each request spends however long it takes, so a long list counts for more than a single alert. `Retry-After` says when to retry. → `Problem` (`application/problem+json`) ### Example request (sandbox) ```bash curl -X POST "https://sandbox.api.safeonuba.com/v1/webhooks/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90/test" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` # What your destination receives Each event reaches your URL as a `POST` with this body. Both groups share the format: what changes is what goes inside `data`. ## Changes to an alert Your destination receives a `POST` for each `alert.*` event. It is signed; it carries no token. One `POST` per event to each subscribed destination, with the complete object. Headers: `X-SafeOnuba-Event-Id`, `X-SafeOnuba-Timestamp` (Unix seconds) and `X-SafeOnuba-Signature: v1=`. Reject anything more than 5 min out of sync. During a rotation two signatures arrive, separated by a comma. Acknowledgement: any `2xx` in under 5 s. Retries at 10 s, 30 s, 2 min, 10 min, 30 min, 1 h and then every hour up to 24 h. “At least once” delivery (deduplicate by `id`) and **no guaranteed order** (use `date_last_modified`). SOS, falls, “I can’t evacuate” and evacuations go out before the rest. ### Body (`application/json`, schema `Event`) - `id` (string (uuid), required): For deduplication: delivery is “at least once”. - `type` (EventType, required) Values: `alert.created`, `alert.acknowledged`, `alert.resolved`, `alert.reopened`, `alert.worker_acknowledged`, `muster.started`, `muster.kind_changed`, `muster.phase_changed`, `muster.targets_changed`, `muster.updated`, `muster.worker_missing`, `muster.ended`. - `api_version` (string, required) Values: `2026-09-24`. - `created_at` (string (date-time), required) - `organization_id` (string (uuid) | null, required): The site’s company. `null` in test events. - `site_id` (string (uuid), required) - `data` ({ alert } | { muster }, required): The complete object exactly as the `GET` would return it at that moment. - Variant 1: - `alert` (Alert, required) - `id` (string (uuid), required) - `type` (AlertType, required) Values: `sos`, `fall_detected`, `restricted_zone_entry`, `forbidden_zone_entry`, `permit_expired_inside`, `permit_closed_inside`, `heat_stress`, `evacuation_help`, `worker_call_request`, `device_offline`, `low_battery`, `earthquake`, `tsunami_risk`. - `severity` (Severity, required) Values: `low`, `medium`, `high`, `critical`. - `status` (AlertStatus, required) Values: `unacknowledged`, `acknowledged`, `resolved`. - `title` (string, required): With `include_worker_names`, the title the control room reads (it carries the person’s name). Without it, one composed of the type, the zone and the `external_id`, with no name. - `date_created` (string (date-time), required) - `date_last_modified` (string (date-time), required): For ordering: webhooks don’t guarantee order, and anything older than what you already have is discarded. - `site` (SiteRef, required) - `id` (string (uuid), required) - `name` (string, required) - `zone` (ZoneRef, required) - `id` (string (uuid), required) - `name` (string, required) - `worker` (WorkerRef, required) - `id` (string (uuid), required) - `external_id` (string | null, required): The worker’s code at the company (the one in its personnel system). `null` if not given. - `name` (string): Full name. **Only** if the contract includes it; otherwise the property doesn’t appear. - `contractor` (string | null, required) - `device` (DeviceRef, required) - `id` (string (uuid), required) - `serial` (string, required) - `battery` (integer | null, required) (from 0 to 100) - `date_last_seen` (string (date-time) | null, required) - `location` (Location, required) - `latitude` (number, required) (from -90 to 90) - `longitude` (number, required) (from -180 to 180) - `uncertainty_m` (number | null, required): Uncertainty radius in metres. `null` if unknown: not knowing it is not the same as it being perfect. (minimum 0) - `source` (string, required): `zone`: the person was in a privacy zone and the point is the zone’s, not theirs. Values: `gps`, `network`, `zone`. - `masked` (boolean, required): The point is the representative one for a privacy zone. - `position_date_utc` (string (date-time), required) - `height` (object | null, required): Height above ground by barometer, when it can be stated; `null` if not. - `location_withheld` (boolean, required): The position exists but is not delivered: in “emergencies only”, an alert below the severity that lifts the veil doesn’t show it, and neither does the dashboard. - `details` (AlertDetails, required) - Variant `SosDetails`: - `trigger` (string, required): How it was requested: on the watch screen, with the physical button, after an unanswered fall, or from the control room. Values: `watch_button`, `watch_hardware_button`, `after_fall`, `control_room`. - Variant `FallDetails`: - `escalated_to_sos` (boolean, required): Did not answer the watch’s “Are you OK?” and it escalated to SOS. - Variant `ZoneDetails`: - `zone_type` (string, required) Values: `restricted`, `forbidden`. - `permit_reference` (string | null, required): The permit to work (job safety analysis, JSA) in force, if there was one. - Variant `HeatStressDetails`: - `action` (string, required): What the watch has told them: stop, drink and find shade. There are no levels. Values: `stop_and_rest`. - `heat_index_c` (number | null, required): The plant’s heat index (°C). - `sun_exposure_minutes` (integer | null, required) - Variant `EvacuationHelpDetails`: - `reason` (string | null, required): What they said on the watch: trapped, injured or helping someone else. Values: `trapped`, `injured`, `helping`. - `muster_id` (string (uuid), required) - Variant `WorkerCallDetails`: - `voice_note` (boolean, required): With no coverage, the watch recorded a voice message (it can be heard in the dashboard). - Variant `DeviceOfflineDetails`: - `minutes_silent` (integer, required) - Variant `LowBatteryDetails`: - `battery_pct` (integer, required) (from 0 to 100) - Variant `HazardDetails`: - `magnitude` (number | null, required) - `distance_km` (number | null, required) - `official` (boolean, required): `false`: a SafeOnuba estimate with no official warning. `true`: an official bulletin confirmed by a person. - `felt_at_site` (boolean, required) - `eta` (string (date-time) | null, required): Estimated arrival of the wave (tsunami). - `nearby` (array, required) - `kind` (string, required) Values: `person`, `vehicle`. - `worker` (object, required) - `vehicle_type` (string | null, required) - `distance_m` (integer, required) - `reported_at` (string (date-time), required) - `vehicle_warnings` (array, required) - `at` (string (date-time), required) - `vehicle_type` (string | null, required) - `distance_m` (integer, required) - `worker_acknowledged_at` (string (date-time) | null, required) - `worker_acknowledged_at` (string (date-time) | null, required): The worker pressed “Got it” on their watch (watch time). - `acknowledged` (ActionBy, required) - `at` (string (date-time), required) - `by` (string, required): Who: a person from the dashboard, or “Client · Operator (via integration)”. - `resolved` (object | null, required): Resolved: by whom, when and with what note. - `at` (string (date-time), required) - `by` (string, required): Who: a person from the dashboard, or “Client · Operator (via integration)”. - `note` (string | null, required) - `assigned_to` (string | null, required) - `simulated` (boolean, required): Raised in a drill or with simulated watches. - `panel_url` (string (uri), required) - Variant 2: - `muster` (Muster, required) - `id` (string (uuid), required) - `site` (SiteRef, required) - `id` (string (uuid), required) - `name` (string, required) - `status` (string, required) Values: `active`, `completed`, `cancelled`. - `phase` (string, required): `inspecting`: headcount complete and the control room is checking the plant; watches say “do not go back until further notice”. Values: `evacuating`, `inspecting`. - `kind` (MusterKind, required) Values: `general`, `tsunami`, `earthquake`. - `reason` (string | null, required) - `started_at` (string (date-time), required) - `ended_at` (string (date-time) | null, required) - `deadline` (object | null, required) - `at` (string (date-time), required) - `source` (string, required): Tsunami: the time of an official warning, or the ceiling in the zone’s plan. Never a physical calculation. Values: `official`, `plan_estimate`. - `origin` (object | null, required): Where the emergency comes from. `null` in an evacuation with no origin (a general drill). - `latitude` (number, required) - `longitude` (number, required) - `radius_m` (integer, required) - `label` (string | null, required) - `zone` (ZoneRef, required) - `targets_revision` (integer, required): Goes up every time the targets change (ruling out, restoring, change of type). - `totals` (object, required): The same figures as the dashboard banner and the closing summary: the server counts them. In an ended evacuation, what remains is what was recorded on ending —arrived and total— and the rest comes out as `null`. - `expected` (integer, required) - `safe` (integer, required) - `help` (integer | null, required) - `wrong_point` (integer | null, required) - `pending` (integer | null, required) - `no_signal` (integer | null, required) - `not_worn` (integer | null, required) - `assembly_points` (array, required): The targets for the type, ruled-out ones included. - `id` (string (uuid), required) - `name` (string, required) - `status` (string, required) Values: `available`, `excluded`. - `count` (integer | null, required): People safe at this point. `null` in an ended evacuation: on ending, only the total is kept. - `excluded` (object | null, required) ### Responses - `200`: Received. Any `2xx` will do. ### Example body ```json { "id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "type": "alert.created", "api_version": "2026-09-24", "created_at": "2026-09-24T11:04:12Z", "organization_id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "site_id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "data": { "alert": { "id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "type": "sos", "severity": "critical", "status": "unacknowledged", "title": "SOS activado — Ana Ejemplo", "date_created": "2026-09-24T11:04:12Z", "date_last_modified": "2026-09-24T11:04:12Z", "site": { "id": "11111111-1111-1111-1111-111111111111", "name": "Planta Ejemplo — simulación" }, "zone": null, "worker": { "id": "33333333-3333-3333-3333-333333333305", "external_id": "EMP-004512", "name": "Ana Ejemplo", "contractor": "Contrata Ejemplo A" }, "device": { "id": "44444444-4444-4444-4444-444444444405", "serial": "OS-W-0005", "battery": 64, "date_last_seen": "2026-09-24T11:04:10Z" }, "location": { "latitude": 40.00121, "longitude": -3.00214, "uncertainty_m": 8, "source": "gps", "masked": false, "position_date_utc": "2026-09-24T11:04:08Z", "height": null }, "location_withheld": false, "details": { "trigger": "watch_button" }, "nearby": [ { "kind": "vehicle", "worker": { "id": "33333333-3333-3333-3333-333333333309", "external_id": "EMP-002210", "name": "Carlos Ejemplo", "contractor": "Contrata Ejemplo B" }, "vehicle_type": "Grúa móvil", "distance_m": 18, "reported_at": "2026-09-24T11:04:02Z" }, { "kind": "person", "worker": { "id": "33333333-3333-3333-3333-333333333301", "external_id": "EMP-000981", "name": "Diego Ejemplo", "contractor": null }, "vehicle_type": null, "distance_m": 26, "reported_at": "2026-09-24T11:03:58Z" } ], "vehicle_warnings": [], "worker_acknowledged_at": null, "acknowledged": null, "resolved": null, "assigned_to": null, "simulated": false, "panel_url": "https://app.safeonuba.com/alertas/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90" } } } ``` ## Changes to an evacuation Your destination receives a `POST` for each `muster.*` event. It is signed; it carries no token. One `POST` per event to each subscribed destination, with the complete object. Headers: `X-SafeOnuba-Event-Id`, `X-SafeOnuba-Timestamp` (Unix seconds) and `X-SafeOnuba-Signature: v1=`. Reject anything more than 5 min out of sync. During a rotation two signatures arrive, separated by a comma. Acknowledgement: any `2xx` in under 5 s. Retries at 10 s, 30 s, 2 min, 10 min, 30 min, 1 h and then every hour up to 24 h. “At least once” delivery (deduplicate by `id`) and **no guaranteed order** (use `date_last_modified`). SOS, falls, “I can’t evacuate” and evacuations go out before the rest. ### Body (`application/json`, schema `Event`) - `id` (string (uuid), required): For deduplication: delivery is “at least once”. - `type` (EventType, required) Values: `alert.created`, `alert.acknowledged`, `alert.resolved`, `alert.reopened`, `alert.worker_acknowledged`, `muster.started`, `muster.kind_changed`, `muster.phase_changed`, `muster.targets_changed`, `muster.updated`, `muster.worker_missing`, `muster.ended`. - `api_version` (string, required) Values: `2026-09-24`. - `created_at` (string (date-time), required) - `organization_id` (string (uuid) | null, required): The site’s company. `null` in test events. - `site_id` (string (uuid), required) - `data` ({ alert } | { muster }, required): The complete object exactly as the `GET` would return it at that moment. - Variant 1: - `alert` (Alert, required) - `id` (string (uuid), required) - `type` (AlertType, required) Values: `sos`, `fall_detected`, `restricted_zone_entry`, `forbidden_zone_entry`, `permit_expired_inside`, `permit_closed_inside`, `heat_stress`, `evacuation_help`, `worker_call_request`, `device_offline`, `low_battery`, `earthquake`, `tsunami_risk`. - `severity` (Severity, required) Values: `low`, `medium`, `high`, `critical`. - `status` (AlertStatus, required) Values: `unacknowledged`, `acknowledged`, `resolved`. - `title` (string, required): With `include_worker_names`, the title the control room reads (it carries the person’s name). Without it, one composed of the type, the zone and the `external_id`, with no name. - `date_created` (string (date-time), required) - `date_last_modified` (string (date-time), required): For ordering: webhooks don’t guarantee order, and anything older than what you already have is discarded. - `site` (SiteRef, required) - `id` (string (uuid), required) - `name` (string, required) - `zone` (ZoneRef, required) - `id` (string (uuid), required) - `name` (string, required) - `worker` (WorkerRef, required) - `id` (string (uuid), required) - `external_id` (string | null, required): The worker’s code at the company (the one in its personnel system). `null` if not given. - `name` (string): Full name. **Only** if the contract includes it; otherwise the property doesn’t appear. - `contractor` (string | null, required) - `device` (DeviceRef, required) - `id` (string (uuid), required) - `serial` (string, required) - `battery` (integer | null, required) (from 0 to 100) - `date_last_seen` (string (date-time) | null, required) - `location` (Location, required) - `latitude` (number, required) (from -90 to 90) - `longitude` (number, required) (from -180 to 180) - `uncertainty_m` (number | null, required): Uncertainty radius in metres. `null` if unknown: not knowing it is not the same as it being perfect. (minimum 0) - `source` (string, required): `zone`: the person was in a privacy zone and the point is the zone’s, not theirs. Values: `gps`, `network`, `zone`. - `masked` (boolean, required): The point is the representative one for a privacy zone. - `position_date_utc` (string (date-time), required) - `height` (object | null, required): Height above ground by barometer, when it can be stated; `null` if not. - `location_withheld` (boolean, required): The position exists but is not delivered: in “emergencies only”, an alert below the severity that lifts the veil doesn’t show it, and neither does the dashboard. - `details` (AlertDetails, required) - Variant `SosDetails`: - `trigger` (string, required): How it was requested: on the watch screen, with the physical button, after an unanswered fall, or from the control room. Values: `watch_button`, `watch_hardware_button`, `after_fall`, `control_room`. - Variant `FallDetails`: - `escalated_to_sos` (boolean, required): Did not answer the watch’s “Are you OK?” and it escalated to SOS. - Variant `ZoneDetails`: - `zone_type` (string, required) Values: `restricted`, `forbidden`. - `permit_reference` (string | null, required): The permit to work (job safety analysis, JSA) in force, if there was one. - Variant `HeatStressDetails`: - `action` (string, required): What the watch has told them: stop, drink and find shade. There are no levels. Values: `stop_and_rest`. - `heat_index_c` (number | null, required): The plant’s heat index (°C). - `sun_exposure_minutes` (integer | null, required) - Variant `EvacuationHelpDetails`: - `reason` (string | null, required): What they said on the watch: trapped, injured or helping someone else. Values: `trapped`, `injured`, `helping`. - `muster_id` (string (uuid), required) - Variant `WorkerCallDetails`: - `voice_note` (boolean, required): With no coverage, the watch recorded a voice message (it can be heard in the dashboard). - Variant `DeviceOfflineDetails`: - `minutes_silent` (integer, required) - Variant `LowBatteryDetails`: - `battery_pct` (integer, required) (from 0 to 100) - Variant `HazardDetails`: - `magnitude` (number | null, required) - `distance_km` (number | null, required) - `official` (boolean, required): `false`: a SafeOnuba estimate with no official warning. `true`: an official bulletin confirmed by a person. - `felt_at_site` (boolean, required) - `eta` (string (date-time) | null, required): Estimated arrival of the wave (tsunami). - `nearby` (array, required) - `kind` (string, required) Values: `person`, `vehicle`. - `worker` (object, required) - `vehicle_type` (string | null, required) - `distance_m` (integer, required) - `reported_at` (string (date-time), required) - `vehicle_warnings` (array, required) - `at` (string (date-time), required) - `vehicle_type` (string | null, required) - `distance_m` (integer, required) - `worker_acknowledged_at` (string (date-time) | null, required) - `worker_acknowledged_at` (string (date-time) | null, required): The worker pressed “Got it” on their watch (watch time). - `acknowledged` (ActionBy, required) - `at` (string (date-time), required) - `by` (string, required): Who: a person from the dashboard, or “Client · Operator (via integration)”. - `resolved` (object | null, required): Resolved: by whom, when and with what note. - `at` (string (date-time), required) - `by` (string, required): Who: a person from the dashboard, or “Client · Operator (via integration)”. - `note` (string | null, required) - `assigned_to` (string | null, required) - `simulated` (boolean, required): Raised in a drill or with simulated watches. - `panel_url` (string (uri), required) - Variant 2: - `muster` (Muster, required) - `id` (string (uuid), required) - `site` (SiteRef, required) - `id` (string (uuid), required) - `name` (string, required) - `status` (string, required) Values: `active`, `completed`, `cancelled`. - `phase` (string, required): `inspecting`: headcount complete and the control room is checking the plant; watches say “do not go back until further notice”. Values: `evacuating`, `inspecting`. - `kind` (MusterKind, required) Values: `general`, `tsunami`, `earthquake`. - `reason` (string | null, required) - `started_at` (string (date-time), required) - `ended_at` (string (date-time) | null, required) - `deadline` (object | null, required) - `at` (string (date-time), required) - `source` (string, required): Tsunami: the time of an official warning, or the ceiling in the zone’s plan. Never a physical calculation. Values: `official`, `plan_estimate`. - `origin` (object | null, required): Where the emergency comes from. `null` in an evacuation with no origin (a general drill). - `latitude` (number, required) - `longitude` (number, required) - `radius_m` (integer, required) - `label` (string | null, required) - `zone` (ZoneRef, required) - `targets_revision` (integer, required): Goes up every time the targets change (ruling out, restoring, change of type). - `totals` (object, required): The same figures as the dashboard banner and the closing summary: the server counts them. In an ended evacuation, what remains is what was recorded on ending —arrived and total— and the rest comes out as `null`. - `expected` (integer, required) - `safe` (integer, required) - `help` (integer | null, required) - `wrong_point` (integer | null, required) - `pending` (integer | null, required) - `no_signal` (integer | null, required) - `not_worn` (integer | null, required) - `assembly_points` (array, required): The targets for the type, ruled-out ones included. - `id` (string (uuid), required) - `name` (string, required) - `status` (string, required) Values: `available`, `excluded`. - `count` (integer | null, required): People safe at this point. `null` in an ended evacuation: on ending, only the total is kept. - `excluded` (object | null, required) ### Responses - `200`: Received. Any `2xx` will do. ### Example body ```json { "id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "type": "alert.created", "api_version": "2026-09-24", "created_at": "2026-09-24T11:04:12Z", "organization_id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "site_id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "data": { "alert": { "id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "type": "sos", "severity": "critical", "status": "unacknowledged", "title": "SOS activado — Ana Ejemplo", "date_created": "2026-09-24T11:04:12Z", "date_last_modified": "2026-09-24T11:04:12Z", "site": { "id": "11111111-1111-1111-1111-111111111111", "name": "Planta Ejemplo — simulación" }, "zone": null, "worker": { "id": "33333333-3333-3333-3333-333333333305", "external_id": "EMP-004512", "name": "Ana Ejemplo", "contractor": "Contrata Ejemplo A" }, "device": { "id": "44444444-4444-4444-4444-444444444405", "serial": "OS-W-0005", "battery": 64, "date_last_seen": "2026-09-24T11:04:10Z" }, "location": { "latitude": 40.00121, "longitude": -3.00214, "uncertainty_m": 8, "source": "gps", "masked": false, "position_date_utc": "2026-09-24T11:04:08Z", "height": null }, "location_withheld": false, "details": { "trigger": "watch_button" }, "nearby": [ { "kind": "vehicle", "worker": { "id": "33333333-3333-3333-3333-333333333309", "external_id": "EMP-002210", "name": "Carlos Ejemplo", "contractor": "Contrata Ejemplo B" }, "vehicle_type": "Grúa móvil", "distance_m": 18, "reported_at": "2026-09-24T11:04:02Z" }, { "kind": "person", "worker": { "id": "33333333-3333-3333-3333-333333333301", "external_id": "EMP-000981", "name": "Diego Ejemplo", "contractor": null }, "vehicle_type": null, "distance_m": 26, "reported_at": "2026-09-24T11:03:58Z" } ], "vehicle_warnings": [], "worker_acknowledged_at": null, "acknowledged": null, "resolved": null, "assigned_to": null, "simulated": false, "panel_url": "https://app.safeonuba.com/alertas/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90" } } } ``` --- # Sandbox · Reference > Test scenarios with simulated watches, so you can integrate without watches. Only on `sandbox.api.safeonuba.com`. Page: https://safeonuba.com/en/developers/reference/sandbox · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. ## Launch a test scenario `POST /v1/sandbox/scenarios` · any token · [see it on the web](https://safeonuba.com/en/developers/reference/sandbox#runSandboxScenario) **Only on `sandbox.api.safeonuba.com`**; in production, 404. A scenario doesn’t invent alerts: simulated watches at a sandbox site send their facts and the usual engine decides, so what arrives —webhook, `GET`, headcount— is the same as would arrive from a real watch, marked `simulated: true`. At most five open at once per client and one evacuation per site. ### Body (`application/json`, schema `SandboxScenarioRequest`) - `scenario` (SandboxScenario, required) Values: `sos`, `fall`, `heat_stress`, `evacuation`. - `site_id` (string (uuid)): The sandbox site. Without it, the first one of yours that is a sandbox site. ### Responses - `202`: Launched. → `SandboxScenarioRun` (`application/json`) - `401`: The token is missing, has expired or has been revoked. → `Problem` (`application/problem+json`) - `403`: The token lacks the scope the route requires, or the site doesn’t belong to the client. → `Problem` (`application/problem+json`) - `404`: This is not the sandbox, or the site isn’t yours. → `Problem` (`application/problem+json`) - `409`: An evacuation is already under way at that site, or five scenarios are open. → `Problem` (`application/problem+json`) - `422`: The site isn’t a sandbox site, or it has a real watch and nothing runs. → `Problem` (`application/problem+json`) - `429`: Too many requests, or too much query time: each client has a number of requests per minute (600 by default) and a query time per minute (30 s by default), and each request spends however long it takes, so a long list counts for more than a single alert. `Retry-After` says when to retry. → `Problem` (`application/problem+json`) ### Example request (sandbox) ```bash curl -X POST "https://sandbox.api.safeonuba.com/v1/sandbox/scenarios" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "scenario": "sos" }' ``` ### Example response (`202`) ```json { "run_id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "scenario": "sos", "site_id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90", "resource": { "type": "alert", "id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90" }, "ends_at": "2026-09-24T11:04:12Z" } ``` --- # Schemas · Reference > The objects the API accepts and returns, field by field, exactly as the OpenAPI contract defines them. Page: https://safeonuba.com/en/developers/reference/schemas · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change. ## Event Type: `object` - `id` (string (uuid), required): For deduplication: delivery is “at least once”. - `type` (EventType, required) Values: `alert.created`, `alert.acknowledged`, `alert.resolved`, `alert.reopened`, `alert.worker_acknowledged`, `muster.started`, `muster.kind_changed`, `muster.phase_changed`, `muster.targets_changed`, `muster.updated`, `muster.worker_missing`, `muster.ended`. - `api_version` (string, required) Values: `2026-09-24`. - `created_at` (string (date-time), required) - `organization_id` (string (uuid) | null, required): The site’s company. `null` in test events. - `site_id` (string (uuid), required) - `data` ({ alert } | { muster }, required): The complete object exactly as the `GET` would return it at that moment. - Variant 1: - `alert` (Alert, required) - Variant 2: - `muster` (Muster, required) ## EventType Type: `string` `muster.updated`: the headcount has changed (checked every 5 s, only emitted if it changes). `muster.targets_changed`: the control room has ruled out or restored a muster point. Values: `alert.created`, `alert.acknowledged`, `alert.resolved`, `alert.reopened`, `alert.worker_acknowledged`, `muster.started`, `muster.kind_changed`, `muster.phase_changed`, `muster.targets_changed`, `muster.updated`, `muster.worker_missing`, `muster.ended`. ## Alert Type: `object` - `id` (string (uuid), required) - `type` (AlertType, required) Values: `sos`, `fall_detected`, `restricted_zone_entry`, `forbidden_zone_entry`, `permit_expired_inside`, `permit_closed_inside`, `heat_stress`, `evacuation_help`, `worker_call_request`, `device_offline`, `low_battery`, `earthquake`, `tsunami_risk`. - `severity` (Severity, required) Values: `low`, `medium`, `high`, `critical`. - `status` (AlertStatus, required) Values: `unacknowledged`, `acknowledged`, `resolved`. - `title` (string, required): With `include_worker_names`, the title the control room reads (it carries the person’s name). Without it, one composed of the type, the zone and the `external_id`, with no name. - `date_created` (string (date-time), required) - `date_last_modified` (string (date-time), required): For ordering: webhooks don’t guarantee order, and anything older than what you already have is discarded. - `site` (SiteRef, required) - `zone` (ZoneRef, required) - `worker` (WorkerRef, required) - `device` (DeviceRef, required) - `location` (Location, required) - `location_withheld` (boolean, required): The position exists but is not delivered: in “emergencies only”, an alert below the severity that lifts the veil doesn’t show it, and neither does the dashboard. - `details` (AlertDetails, required) - `nearby` (array, required) - `vehicle_warnings` (array, required) - `worker_acknowledged_at` (string (date-time) | null, required): The worker pressed “Got it” on their watch (watch time). - `acknowledged` (ActionBy, required) - `resolved` (object | null, required): Resolved: by whom, when and with what note. - `at` (string (date-time), required) - `by` (string, required): Who: a person from the dashboard, or “Client · Operator (via integration)”. - `note` (string | null, required) - `assigned_to` (string | null, required) - `simulated` (boolean, required): Raised in a drill or with simulated watches. - `panel_url` (string (uri), required) ## AlertType Type: `string` What has happened. `earthquake` and `tsunami_risk` concern the whole plant: they carry no `worker` or `device`. A nearby vehicle is **not** an alert: it appears inside an incident’s alert (`nearby`, `vehicle_warnings`). Values: `sos`, `fall_detected`, `restricted_zone_entry`, `forbidden_zone_entry`, `permit_expired_inside`, `permit_closed_inside`, `heat_stress`, `evacuation_help`, `worker_call_request`, `device_offline`, `low_battery`, `earthquake`, `tsunami_risk`. ## Severity Type: `string` Severity: `low` (information), `medium` (warning), `high` (serious), `critical` (critical: SOS, fall, “I can’t evacuate”). Values: `low`, `medium`, `high`, `critical`. ## AlertStatus Type: `string` `acknowledged`: an operator has taken charge. It is not the same as `worker_acknowledged_at`, which means the worker has seen the alert on their watch. Values: `unacknowledged`, `acknowledged`, `resolved`. ## SiteRef Type: `object` - `id` (string (uuid), required) - `name` (string, required) ## ZoneRef Type: `object | null` - `id` (string (uuid), required) - `name` (string, required) ## WorkerRef Type: `object | null` - `id` (string (uuid), required) - `external_id` (string | null, required): The worker’s code at the company (the one in its personnel system). `null` if not given. - `name` (string): Full name. **Only** if the contract includes it; otherwise the property doesn’t appear. - `contractor` (string | null, required) ## DeviceRef Type: `object | null` - `id` (string (uuid), required) - `serial` (string, required) - `battery` (integer | null, required) (from 0 to 100) - `date_last_seen` (string (date-time) | null, required) ## Location Type: `object | null` - `latitude` (number, required) (from -90 to 90) - `longitude` (number, required) (from -180 to 180) - `uncertainty_m` (number | null, required): Uncertainty radius in metres. `null` if unknown: not knowing it is not the same as it being perfect. (minimum 0) - `source` (string, required): `zone`: the person was in a privacy zone and the point is the zone’s, not theirs. Values: `gps`, `network`, `zone`. - `masked` (boolean, required): The point is the representative one for a privacy zone. - `position_date_utc` (string (date-time), required) - `height` (object | null, required): Height above ground by barometer, when it can be stated; `null` if not. - `height_m` (number, required) - `uncertainty_m` (number, required) - `building` (string | null, required) - `floor` (string | null, required) ## AlertDetails Type: `SosDetails | FallDetails | ZoneDetails | HeatStressDetails | EvacuationHelpDetails | WorkerCallDetails | DeviceOfflineDetails | LowBatteryDetails | HazardDetails` Depends on `type`. ## SosDetails Type: `object` - `trigger` (string, required): How it was requested: on the watch screen, with the physical button, after an unanswered fall, or from the control room. Values: `watch_button`, `watch_hardware_button`, `after_fall`, `control_room`. ## FallDetails Type: `object` - `escalated_to_sos` (boolean, required): Did not answer the watch’s “Are you OK?” and it escalated to SOS. ## ZoneDetails Type: `object` - `zone_type` (string, required) Values: `restricted`, `forbidden`. - `permit_reference` (string | null, required): The permit to work (job safety analysis, JSA) in force, if there was one. ## HeatStressDetails Type: `object` Nothing derived from heart rate: vital signs don’t leave through the API, not even aggregated. - `action` (string, required): What the watch has told them: stop, drink and find shade. There are no levels. Values: `stop_and_rest`. - `heat_index_c` (number | null, required): The plant’s heat index (°C). - `sun_exposure_minutes` (integer | null, required) ## EvacuationHelpDetails Type: `object` - `reason` (string | null, required): What they said on the watch: trapped, injured or helping someone else. Values: `trapped`, `injured`, `helping`. - `muster_id` (string (uuid), required) ## WorkerCallDetails Type: `object` - `voice_note` (boolean, required): With no coverage, the watch recorded a voice message (it can be heard in the dashboard). ## DeviceOfflineDetails Type: `object` - `minutes_silent` (integer, required) ## LowBatteryDetails Type: `object` - `battery_pct` (integer, required) (from 0 to 100) ## HazardDetails Type: `object` - `magnitude` (number | null, required) - `distance_km` (number | null, required) - `official` (boolean, required): `false`: a SafeOnuba estimate with no official warning. `true`: an official bulletin confirmed by a person. - `felt_at_site` (boolean, required) - `eta` (string (date-time) | null, required): Estimated arrival of the wave (tsunami). ## Nearby Type: `object` Who was within 100 m when a critical alert was raised (SOS, fall). Distance, never position; anyone in a privacy zone doesn’t count. - `kind` (string, required) Values: `person`, `vehicle`. - `worker` (object, required) - `id` (string (uuid) | null, required): `null` in alerts from before 24 September 2026, when it was not yet being copied. - `external_id` (string | null, required): The worker’s code at the company (the one in its personnel system). `null` if not given. - `name` (string): Full name. **Only** if the contract includes it; otherwise the property doesn’t appear. - `contractor` (string | null, required) - `vehicle_type` (string | null, required) - `distance_m` (integer, required) - `reported_at` (string (date-time), required) ## VehicleWarning Type: `object` Nearby-vehicle warnings that person received during the incident window. They are warnings, not alerts. - `at` (string (date-time), required) - `vehicle_type` (string | null, required) - `distance_m` (integer, required) - `worker_acknowledged_at` (string (date-time) | null, required) ## ActionBy Type: `object | null` - `at` (string (date-time), required) - `by` (string, required): Who: a person from the dashboard, or “Client · Operator (via integration)”. ## Muster Type: `object` - `id` (string (uuid), required) - `site` (SiteRef, required) - `status` (string, required) Values: `active`, `completed`, `cancelled`. - `phase` (string, required): `inspecting`: headcount complete and the control room is checking the plant; watches say “do not go back until further notice”. Values: `evacuating`, `inspecting`. - `kind` (MusterKind, required) Values: `general`, `tsunami`, `earthquake`. - `reason` (string | null, required) - `started_at` (string (date-time), required) - `ended_at` (string (date-time) | null, required) - `deadline` (object | null, required) - `at` (string (date-time), required) - `source` (string, required): Tsunami: the time of an official warning, or the ceiling in the zone’s plan. Never a physical calculation. Values: `official`, `plan_estimate`. - `origin` (object | null, required): Where the emergency comes from. `null` in an evacuation with no origin (a general drill). - `latitude` (number, required) - `longitude` (number, required) - `radius_m` (integer, required) - `label` (string | null, required) - `zone` (ZoneRef, required) - `targets_revision` (integer, required): Goes up every time the targets change (ruling out, restoring, change of type). - `totals` (object, required): The same figures as the dashboard banner and the closing summary: the server counts them. In an ended evacuation, what remains is what was recorded on ending —arrived and total— and the rest comes out as `null`. - `expected` (integer, required) - `safe` (integer, required) - `help` (integer | null, required) - `wrong_point` (integer | null, required) - `pending` (integer | null, required) - `no_signal` (integer | null, required) - `not_worn` (integer | null, required) - `assembly_points` (array, required): The targets for the type, ruled-out ones included. ## MusterKind Type: `string` Values: `general`, `tsunami`, `earthquake`. ## AssemblyPoint Type: `object` - `id` (string (uuid), required) - `name` (string, required) - `status` (string, required) Values: `available`, `excluded`. - `count` (integer | null, required): People safe at this point. `null` in an ended evacuation: on ending, only the total is kept. - `excluded` (object | null, required) - `reason` (string, required) - `recommended` (boolean, required): The system had recommended it (radius or wind). Whoever rules it out is always a person. - `at` (string (date-time), required) - `by` (string, required) ## TokenResponse Type: `object` - `access_token` (string, required) - `token_type` (string, required) Values: `Bearer`. - `expires_in` (number, required) Values: `3600`. - `scope` (string, required) ## OAuthError Type: `object` Token error, in the RFC 6749 §5.2 format. - `error` (string, required) Values: `invalid_request`, `invalid_client`, `unauthorized_client`, `unsupported_grant_type`, `invalid_scope`. - `error_description` (string) ## TokenRequest Type: `object` - `grant_type` (string, required) Values: `client_credentials`. - `client_id` (string, required) - `client_secret` (string, required) - `scope` (string): Space-separated; a subset of the client’s. Without it, all of the client’s. ## SiteList Type: `object` - `data` (array, required) ## Site Type: `object` - `id` (string (uuid), required) - `name` (string, required) - `location` (string | null, required) - `privacy_mode` (string, required): `on_demand` (“emergencies only”): a person’s position is only visible with an open alert from `unveil_min_severity`, during an evacuation, or with an approved request. The API obeys the same rule as the dashboard. Values: `continuous`, `on_demand`. - `unveil_min_severity` (Severity, required) Values: `low`, `medium`, `high`, `critical`. ## Problem Type: `object` Error in RFC 9457 format (`application/problem+json`), with the real HTTP status code. - `type` (string, required): URI that identifies the problem type. - `title` (string, required) - `status` (integer, required) - `detail` (string) - `instance` (string) - `code` (string, required): Stable error code, to program against. The text may change; this won’t. ## ZoneList Type: `object` - `data` (array, required) ## Zone Type: `object` - `id` (string (uuid), required) - `name` (string, required) - `type` (string, required) Values: `work_area`, `restricted`, `forbidden`, `assembly_point`, `privacy`, `building`. - `geometry` (object | null, required): GeoJSON polygon (WGS84). `null` for privacy zones: the name and type are given, not where they are (pending validation with the committee). - `type` (string, required) Values: `Polygon`. - `coordinates` (array>, required) - `assembly_point` (object | null, required): Only on muster points. - `serves` (array, required) Values: `general`, `tsunami`, `earthquake`. - `elevation_m` (number | null, required) - `elevation_source` (string | null, required) Values: `dem`, `verified`. - `vertical` (boolean, required): Vertical refuge (a building). - `capacity` (integer | null, required) ## AlertPage Type: `object` - `data` (array, required) - `next_cursor` (string | null, required): Opaque cursor for the next page; `null` if there are no more. ## AlertUpdate Type: `object` Two states, `acknowledged` and `resolved`, plus the operator. Acknowledging self-assigns the alert if nobody was handling it, as in the dashboard. - `status` (string, required) Values: `acknowledged`, `resolved`. - `resolution_reason` (string): On resolving: why. Free text; it goes in the alert’s timeline next to the note. (up to 200 characters) - `note` (string) (up to 500 characters) - `actor` (Actor, required) ## Actor Type: `object` The operator in the customer’s control room who takes the action. Required: “acknowledged” measures a person’s response, and without a name the timeline would only show the client’s. - `name` (string, required) (up to 120 characters) - `external_id` (string) (up to 64 characters) ## MusterPage Type: `object` - `data` (array, required) - `next_cursor` (string | null, required): Opaque cursor for the next page; `null` if there are no more. ## MissingWorkerList Type: `object` - `data` (array, required) ## MissingWorker Type: `object` - `worker` (WorkerRef, required) - `device` (DeviceRef, required) - `status` (string, required) Values: `help`, `wrong_point`, `pending`, `no_signal`, `not_worn`. - `needs_rescue` (boolean, required): Has an open SOS or fall and has not arrived: cannot leave on their own. - `help_reason` (string | null, required) Values: `trapped`, `injured`, `helping`. - `assembly_point` (object | null, required): The point where they are or said they were (with `wrong_point`, one that is unusable). - `id` (string (uuid), required) - `name` (string, required) - `valid` (boolean, required) - `declared_at` (string (date-time) | null, required) - `last_seen_at` (string (date-time) | null, required) - `location` (Location, required) - `location_withheld` (boolean, required) ## WorkerPage Type: `object` - `data` (array, required) - `next_cursor` (string | null, required): Opaque cursor for the next page; `null` if there are no more. ## Worker Type: `object` - `id` (string (uuid), required) - `external_id` (string | null, required): The worker’s code at the company (the one in its personnel system). `null` if not given. - `name` (string): Full name. **Only** if the contract includes it; otherwise the property doesn’t appear. - `contractor` (string | null, required) - `role` (string | null, required) - `is_active` (boolean, required) - `device` (DeviceRef, required) - `date_last_seen` (string (date-time) | null, required): The most recent of the last position and the watch’s last contact. ## DevicePage Type: `object` - `data` (array, required) - `next_cursor` (string | null, required): Opaque cursor for the next page; `null` if there are no more. ## Device Type: `object` - `id` (string (uuid), required) - `serial` (string, required) - `battery` (integer | null, required) (from 0 to 100) - `date_last_seen` (string (date-time) | null, required) - `model` (string | null, required) - `status` (string, required) Values: `active`, `inactive`, `maintenance`, `lost`. - `worn` (boolean | null, required): `null`: not known (no wrist sensor). - `worker` (WorkerRef, required) ## PositionList Type: `object` - `data` (array, required) ## Position Type: `object` Only the positions the privacy veil allows at that moment: in “emergencies only”, anyone with nothing open doesn’t appear. - `device_id` (string (uuid), required) - `worker` (WorkerRef, required) - `location` (Location, required) - `in_vehicle` (boolean, required) ## EventPage Type: `object` - `data` (array, required) - `next_cursor` (string | null, required): Opaque cursor for the next page; `null` if there are no more. ## WebhookEndpointList Type: `object` - `data` (array, required) ## WebhookEndpoint Type: `object` - `id` (string (uuid), required) - `url` (string (uri), required) - `event_types` (array, required) - `filters` (object, required) - `site_ids` (array) - `min_severity` (Severity) Values: `low`, `medium`, `high`, `critical`. - `contractors` (array) - `is_active` (boolean, required) - `failing_since` (string (date-time) | null, required): Has been failing since then. The destination is **not** disabled automatically: the next SOS would stop arriving without anyone deciding it. - `created_at` (string (date-time), required) ## WebhookEndpointCreated Type: `object` - `id` (string (uuid), required) - `url` (string (uri), required) - `event_types` (array, required) - `filters` (object, required) - `site_ids` (array) - `min_severity` (Severity) Values: `low`, `medium`, `high`, `critical`. - `contractors` (array) - `is_active` (boolean, required) - `failing_since` (string (date-time) | null, required): Has been failing since then. The destination is **not** disabled automatically: the next SOS would stop arriving without anyone deciding it. - `created_at` (string (date-time), required) - `secret` (string, required): The signing secret. **Shown only this once.** ## WebhookEndpointCreate Type: `object` - `url` (string, required) - `event_types` (array, required) - `filters` (object) - `site_ids` (array) - `min_severity` (Severity) Values: `low`, `medium`, `high`, `critical`. - `contractors` (array) ## WebhookSecretRotated Type: `object` - `secret` (string, required) - `previous_valid_until` (string (date-time), required) ## SandboxScenarioRun Type: `object` - `run_id` (string (uuid), required) - `scenario` (SandboxScenario, required) Values: `sos`, `fall`, `heat_stress`, `evacuation`. - `site_id` (string (uuid), required) - `resource` (object, required): What it has opened: check it with its `GET`, or wait for the webhook. - `type` (string, required) Values: `alert`, `muster`. - `id` (string (uuid), required) - `ends_at` (string (date-time), required): When it closes by itself. ## SandboxScenario Type: `string` - `sos`, `fall`, `heat_stress`: a test person presses SOS, falls or suffers heat stress. The alert goes out (`alert.created`) and, if nobody resolves it first with `PUT /v1/alerts/{id}`, it resolves itself after 10 min (`alert.resolved`). - `evacuation`: a general evacuation of 3 min. At 15 s four people arrive at point A; at 35 s someone arrives at B and the control room rules it out (`muster.targets_changed`); at 55 s a person asks for help (`alert.created`, `evacuation_help`); at 95 s whoever was at B moves on to C; at 3 min, all clear (`muster.ended`). In between, `muster.updated` every time the headcount changes. Values: `sos`, `fall`, `heat_stress`, `evacuation`. ## SandboxScenarioRequest Type: `object` - `scenario` (SandboxScenario, required) Values: `sos`, `fall`, `heat_stress`, `evacuation`. - `site_id` (string (uuid)): The sandbox site. Without it, the first one of yours that is a sandbox site.