# API de SafeOnuba > Recibe las alertas y evacuaciones de SafeOnuba en tu sala de control, tu SCADA, tu PSIM o tu intranet, y reconoce o cierra alertas desde allí. Página: https://safeonuba.com/desarrolladores · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. > **v1 · vista previa** > > El contrato está pendiente del visto bueno del primer cliente y puede cambiar. Cualquier cambio quedará anotado en el [changelog](https://safeonuba.com/desarrolladores/changelog). La API de SafeOnuba lleva las alertas y las evacuaciones de tu planta a los sistemas que ya usa tu sala de control: un SCADA, un PSIM o la intranet. Está pensada para que el operador de tu sala vea lo mismo que ve el panel de SafeOnuba, en su propia pantalla, y pueda responder desde allí. ## Qué puedes hacer - **Recibir en tiempo real** cada alerta y cada cambio de una evacuación con [webhooks](https://safeonuba.com/desarrolladores/webhooks): un `POST` firmado a tu servidor por cada evento. - **Consultar** alertas, evacuaciones y su recuento, quién falta, trabajadores, relojes, centros, zonas y las posiciones que la privacidad de la planta deja ver. - **Reconocer y cerrar alertas** desde tu sala con `PUT /v1/alerts/{id}`. Siempre en nombre de un operador, que queda en la cronología de la alerta. - **Recuperar lo perdido** si tu receptor se cae: `GET /v1/events` devuelve los eventos de los últimos 30 días, byte a byte iguales a los que se enviaron. - **Integrar sin relojes** en el [sandbox](https://safeonuba.com/desarrolladores/sandbox), con escenarios simulados de SOS, caída, estrés térmico y una evacuación completa. ## Qué no hace - **En v1 no abre evacuaciones.** El sistema propone y una persona decide en el panel de SafeOnuba. Tu sala recibe la evacuación en cuanto empieza y la sigue hasta que termina. - **No da constantes vitales**, ni sueltas ni agregadas. Una alerta de estrés térmico dice qué se le ha indicado al trabajador, no su pulso. - **No ve más que el panel.** La API ve lo mismo que vería una persona del panel con esos permisos. Lo explica [Privacidad y seguridad](https://safeonuba.com/desarrolladores/privacidad-y-seguridad). ## Entornos | Entorno | Dirección | Estado | | --- | --- | --- | | Producción | `https://api.safeonuba.com` | Se activa al dar de alta al cliente. Hoy todavía no está abierta al público. | | Pruebas (sandbox) | `https://sandbox.api.safeonuba.com` | Relojes simulados. Es donde se integra. | Las dos tienen las mismas rutas y el mismo contrato. Lo único que cambia al pasar a producción es la dirección y las credenciales. ## Convenciones - Todas las rutas van bajo `/v1`, y cada evento lleva `api_version: "2026-09-24"`. Más en [Versiones](https://safeonuba.com/desarrolladores/versiones). - Cuerpos en JSON, salvo la petición del token, que es `application/x-www-form-urlencoded`. - Los identificadores son UUID. Las fechas, ISO 8601 en UTC. - Los errores llegan en `application/problem+json` con el código HTTP real. Ver [Errores](https://safeonuba.com/desarrolladores/errores). - Las listas se paginan con cursor. Ver [Paginación](https://safeonuba.com/desarrolladores/paginacion). - Dos rutas son públicas y no piden token: `GET /v1/health` y `GET /v1/openapi.json`, el contrato. > **Llama a la API desde tu servidor** > > La API no tiene CORS: un navegador no puede llamarla directamente. Las peticiones salen de tu backend, que es además donde debe vivir el `client_secret`. Por eso esta documentación no tiene botón de «probar»: los ejemplos son código para copiar. ## Cómo encaja en tu sala El camino habitual tiene tres piezas: 1. **Un receptor de webhooks** en tu red, con una URL pública en https. Verifica la firma, responde `2xx` enseguida y deja el evento en una cola. 2. **Tu sistema de sala** consume esa cola y pinta la alerta o el banner de evacuación. Cada alerta trae `panel_url`, el enlace a su ficha en el panel de SafeOnuba, por si el operador necesita el detalle. 3. **Llamadas puntuales a la API** cuando hacen falta: la lista de quién falta en una evacuación, reconocer una alerta, o `GET /v1/events` para ponerse al día tras un corte. Desde que salta un SOS hasta que llega a tu receptor pasan normalmente 1 o 2 segundos. Es una medición típica, no un compromiso de servicio. ## Usar esta documentación con IA Si integras con un asistente de IA, dale la documentación en Markdown en vez de la URL: la lee entera, sin menús ni pestañas, y con los enlaces ya completos. - El botón **Copiar**, arriba a la derecha de cada página, copia esa página en Markdown. - Cualquier página tiene su versión en texto añadiendo `.md` a la dirección: [/desarrolladores/webhooks.md](https://safeonuba.com/desarrolladores/webhooks.md). - [/llms.txt](https://safeonuba.com/llms.txt) es el índice para las herramientas que lo leen solas, y [/llms-full.txt](https://safeonuba.com/llms-full.txt) es toda la documentación en un solo texto. - Para generar código con tipos, el [contrato OpenAPI 3.1](https://safeonuba.com/desarrolladores/openapi.json) es la fuente de verdad. ## Todas las operaciones | Operación | Qué hace | Scope | | --- | --- | --- | | [`POST /v1/oauth/token`](https://safeonuba.com/desarrolladores/referencia/autenticacion#createToken) | Pedir un token (client_credentials) | Sin token | | [`GET /v1/sites`](https://safeonuba.com/desarrolladores/referencia/centros#listSites) | Centros del cliente | Cualquiera | | [`GET /v1/sites/{id}/zones`](https://safeonuba.com/desarrolladores/referencia/centros#listSiteZones) | Zonas y puntos de reunión de un centro | `alerts:read` | | [`GET /v1/alerts`](https://safeonuba.com/desarrolladores/referencia/alertas#listAlerts) | Listar alertas | `alerts:read` | | [`GET /v1/alerts/{id}`](https://safeonuba.com/desarrolladores/referencia/alertas#getAlert) | Detalle de una alerta | `alerts:read` | | [`PUT /v1/alerts/{id}`](https://safeonuba.com/desarrolladores/referencia/alertas#updateAlert) | Reconocer o cerrar una alerta | `alerts:write` | | [`GET /v1/musters`](https://safeonuba.com/desarrolladores/referencia/evacuaciones#listMusters) | Listar evacuaciones | `musters:read` | | [`GET /v1/musters/{id}`](https://safeonuba.com/desarrolladores/referencia/evacuaciones#getMuster) | Estado y recuento de una evacuación | `musters:read` | | [`GET /v1/musters/{id}/missing`](https://safeonuba.com/desarrolladores/referencia/evacuaciones#listMissingWorkers) | Quién falta | `musters:read` | | [`GET /v1/workers`](https://safeonuba.com/desarrolladores/referencia/trabajadores#listWorkers) | Listar trabajadores | `workers:read` | | [`GET /v1/workers/{id}`](https://safeonuba.com/desarrolladores/referencia/trabajadores#getWorker) | Detalle de un trabajador | `workers:read` | | [`GET /v1/devices`](https://safeonuba.com/desarrolladores/referencia/trabajadores#listDevices) | Listar relojes | `workers:read` | | [`GET /v1/positions/latest`](https://safeonuba.com/desarrolladores/referencia/posiciones#listLatestPositions) | Últimas posiciones visibles | `positions:read` | | [`GET /v1/events`](https://safeonuba.com/desarrolladores/referencia/eventos#listEvents) | Reproducir eventos | Cualquiera | | [`GET /v1/webhooks`](https://safeonuba.com/desarrolladores/referencia/webhooks#listWebhooks) | Listar destinos | `webhooks:manage` | | [`POST /v1/webhooks`](https://safeonuba.com/desarrolladores/referencia/webhooks#createWebhook) | Crear un destino | `webhooks:manage` | | [`DELETE /v1/webhooks/{id}`](https://safeonuba.com/desarrolladores/referencia/webhooks#deleteWebhook) | Borrar un destino | `webhooks:manage` | | [`POST /v1/webhooks/{id}/rotate-secret`](https://safeonuba.com/desarrolladores/referencia/webhooks#rotateWebhookSecret) | Rotar el secreto de firma | `webhooks:manage` | | [`POST /v1/webhooks/{id}/test`](https://safeonuba.com/desarrolladores/referencia/webhooks#testWebhook) | Enviar un evento de prueba | `webhooks:manage` | | [`POST /v1/sandbox/scenarios`](https://safeonuba.com/desarrolladores/referencia/sandbox#runSandboxScenario) | Lanzar un escenario de prueba | Cualquiera | ## Siguiente paso [Primeros pasos](https://safeonuba.com/desarrolladores/primeros-pasos): del acceso a tu primer webhook en cinco minutos. --- # Primeros pasos > Del acceso a tu primer webhook en cinco minutos: pide un token, haz la primera llamada, crea un destino y lanza una alerta de prueba en el sandbox. Página: https://safeonuba.com/desarrolladores/primeros-pasos · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. Esta guía va del acceso a tu primera alerta recibida, todo en el sandbox. Necesitas un terminal con `curl` y, para el paso 5, una URL pública en https donde recibir webhooks. Los ejemplos leen las credenciales de variables de entorno. No las escribas en el código. ```bash export SAFEONUBA_CLIENT_ID="cliente-ejemplo" export SAFEONUBA_CLIENT_SECRET="cs_…" ``` ## 1. Pide acceso El acceso lo damos de alta nosotros, cliente a cliente, y el motivo queda registrado. La planta ve en su panel (Ajustes → Integraciones) quién tiene acceso, a qué y por qué. Al darte de alta recibes un `client_id` y un `client_secret`, que empieza por `cs_`. **El secreto se enseña una sola vez**: guárdalo en tu gestor de secretos en ese momento. Para pedirlo, escríbenos a [info@safeonuba.com](mailto:info@safeonuba.com?subject=Acceso%20a%20la%20API%20de%20SafeOnuba) con tu empresa, la planta y qué quieres integrar. ## 2. Pide un token El token se pide con OAuth2 `client_credentials` y dura una hora. ```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(); ``` La respuesta: ```json { "access_token": "at_9c1e…", "token_type": "Bearer", "expires_in": 3600, "scope": "alerts:read alerts:write musters:read" } ``` Guarda el `access_token` para los pasos siguientes: ```bash export SAFEONUBA_TOKEN="…" ``` ## 3. Haz la primera llamada `GET /v1/sites` devuelve los centros a los que tienes acceso. Vale con cualquier scope, así que es la mejor forma de comprobar que el token funciona. ```bash curl https://sandbox.api.safeonuba.com/v1/sites \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` Anota el `id` de tu centro de sandbox: lo usarás al lanzar un escenario. ## 4. Crea un destino de webhook Un destino es la URL de tu receptor y los eventos que quieres que le lleguen. Hace falta el scope `webhooks:manage`. ```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.*"] }' ``` La respuesta trae el `secret` de firma, que empieza por `whsec_`. > **El secreto del webhook solo se enseña ahora** > > Guárdalo en el mismo sitio que el `client_secret`. Si lo pierdes, rótalo con `POST /v1/webhooks/{id}/rotate-secret`. ## 5. Recibe y verifica Tu receptor tiene que hacer tres cosas con cada envío: 1. Comprobar la firma de `X-SafeOnuba-Signature` sobre el cuerpo **en bruto**. 2. Responder `2xx` en menos de 5 segundos. 3. Procesar el evento después, fuera de esa petición. [Verificar la firma](https://safeonuba.com/desarrolladores/webhooks/verificar-firma) tiene el receptor completo en Node, Python y C#. Cuando lo tengas desplegado, comprueba que le llega algo: ```bash curl -X POST https://sandbox.api.safeonuba.com/v1/webhooks/ID_DEL_DESTINO/test \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` Llega un `alert.created` marcado `simulated: true` y firmado como uno real. ## 6. Lanza un SOS de prueba En el sandbox, un escenario pone a un reloj simulado a pulsar el SOS. Lo que recibes es lo mismo que recibirías de un reloj de verdad. ```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" }' ``` A tu receptor le llega un `alert.created` con la alerta completa en `data.alert`. Si nadie la cierra, se cierra sola a los 10 minutos. ## 7. Reconócela desde tu sala Con el scope `alerts:write`, tu sala puede hacerse cargo de la alerta. Siempre en nombre de una persona: el `actor` es el operador que la ha reconocido, y queda en la cronología de la alerta. ```bash curl -X PUT https://sandbox.api.safeonuba.com/v1/alerts/ID_DE_LA_ALERTA \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "status": "acknowledged", "actor": { "name": "E. Ejemplo", "external_id": "OP-117" } }' ``` A tu receptor le llega `alert.acknowledged`, y en el panel de SafeOnuba la alerta aparece reconocida «vía integración». ## Siguientes pasos - [Autenticación y scopes](https://safeonuba.com/desarrolladores/autenticacion): cachear el token y rotar el secreto sin cortes. - [Webhooks](https://safeonuba.com/desarrolladores/webhooks): reintentos, duplicados, orden y filtros. - [Sandbox](https://safeonuba.com/desarrolladores/sandbox): el escenario de evacuación completa, de principio a fin. --- # Autenticación y scopes > OAuth2 client_credentials: cómo pedir un token, qué scopes existen, cómo rotar el client_secret sin cortes y qué errores devuelve el endpoint del token. Página: https://safeonuba.com/desarrolladores/autenticacion · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. La API usa OAuth2 con el flujo `client_credentials`: tu servidor cambia sus credenciales por un token de una hora y manda ese token en cada llamada. ## Credenciales Cada cliente se da de alta a mano, con un motivo que queda registrado. Al darte de alta recibes: - un `client_id`, que identifica a tu empresa; - un `client_secret`, que empieza por `cs_` y **se enseña una sola vez**. Un cliente puede tener **dos secretos activos a la vez**. Es lo que permite rotarlo sin cortar la integración (ver [Rotar el secreto](https://safeonuba.com/desarrolladores/autenticacion#rotar-el-client-secret)). El `client_secret` vive en tu servidor, en un gestor de secretos o una variable de entorno. Nunca en un navegador, una aplicación de escritorio o un repositorio. ## Pedir un token `POST /v1/oauth/token`, con el cuerpo en `application/x-www-form-urlencoded`: | Campo | Obligatorio | Valor | | --- | --- | --- | | `grant_type` | Sí | `client_credentials` | | `client_id` | Sí | Tu identificador de cliente. | | `client_secret` | Sí | Tu secreto. | | `scope` | No | Scopes separados por espacios, entre los que tiene tu cliente. Sin él, el token lleva todos los de tu cliente. | ```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(); ``` La respuesta: | Campo | Qué es | | --- | --- | | `access_token` | El token. Es opaco: no intentes leerlo, solo reenvíalo. | | `token_type` | Siempre `Bearer`. | | `expires_in` | Segundos de validez: `3600`. | | `scope` | Los scopes que lleva el token, separados por espacios. | El token se revoca al instante si se da de baja al cliente. ## Usar el token En todas las demás llamadas, en la cabecera `Authorization`: ```http GET /v1/alerts?status=unacknowledged HTTP/1.1 Host: sandbox.api.safeonuba.com Authorization: Bearer ``` ## Renovarlo `client_credentials` no tiene token de refresco: cuando uno caduca se pide otro con las mismas credenciales. Pide uno, guárdalo en memoria y renuévalo un poco antes de que caduque. No pidas un token por cada llamada: el endpoint del token tiene su propio límite por IP. ```javascript let token = null; let caduca = 0; export async function tokenSafeOnuba() { // Se renueva un minuto antes de caducar. if (token && Date.now() < caduca - 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 datos = await res.json(); token = datos.access_token; caduca = Date.now() + datos.expires_in * 1000; return token; } ``` ```python import os import time import requests _token = None _caduca = 0.0 def token_safeonuba() -> str: global _token, _caduca # Se renueva un minuto antes de caducar. if _token and time.time() < _caduca - 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() datos = res.json() _token = datos["access_token"] _caduca = time.time() + datos["expires_in"] return _token ``` ```csharp using System.Net.Http.Json; using System.Text.Json; public sealed class TokenSafeOnuba(HttpClient http) { private readonly SemaphoreSlim _cerrojo = new(1, 1); private string? _token; private DateTimeOffset _caduca; public async Task ObtenerAsync() { await _cerrojo.WaitAsync(); try { // Se renueva un minuto antes de caducar. if (_token is not null && DateTimeOffset.UtcNow < _caduca.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 datos = await res.Content.ReadFromJsonAsync(); _token = datos.GetProperty("access_token").GetString()!; _caduca = DateTimeOffset.UtcNow.AddSeconds(datos.GetProperty("expires_in").GetDouble()); return _token; } finally { _cerrojo.Release(); } } } ``` Si una llamada devuelve `401` con un token que creías vigente (por ejemplo, porque se ha revocado), descarta el que tienes y pide otro una vez. Si vuelve a fallar, no reintentes en bucle: revisa las credenciales. ## Scopes Cada token lleva los scopes que tu cliente tiene concedidos, o el subconjunto que pidas en `scope`. Pide solo los que usa cada servicio: el receptor de webhooks, por ejemplo, no necesita ninguno, porque no llama a la API. | Scope | Para qué | Operaciones | | --- | --- | --- | | `alerts:read` | Leer alertas y zonas. | `GET /v1/sites/{id}/zones`, `GET /v1/alerts`, `GET /v1/alerts/{id}` | | `alerts:write` | Reconocer y cerrar alertas (siempre con el operador). | `PUT /v1/alerts/{id}` | | `positions:read` | Posiciones que el velo deja ver. | `GET /v1/positions/latest` | | `musters:read` | Evacuaciones y recuento. | `GET /v1/musters`, `GET /v1/musters/{id}`, `GET /v1/musters/{id}/missing` | | `workers:read` | Trabajadores y relojes. | `GET /v1/workers`, `GET /v1/workers/{id}`, `GET /v1/devices` | | `webhooks:manage` | Destinos de webhook. | `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` y `POST /v1/sandbox/scenarios` valen con cualquier token válido. `alerts:write` siempre actúa en nombre de una persona: el cuerpo de `PUT /v1/alerts/{id}` exige el `actor`, el operador de tu sala que reconoce o cierra la alerta. ## Rotar el client_secret Como un cliente puede tener dos secretos activos, la rotación no corta nada: 1. Pide un secreto nuevo por el mismo canal por el que pediste el acceso. 2. Despliega el nuevo en tus servicios. Mientras tanto, los que aún usan el antiguo siguen funcionando. 3. Comprueba que todos los tokens se piden ya con el nuevo. 4. Pide que se retire el antiguo. El secreto de firma de los webhooks es otra cosa y se rota por la API: ver [Verificar la firma](https://safeonuba.com/desarrolladores/webhooks/verificar-firma#rotar-el-secreto). ## Errores del token El endpoint del token responde en el formato de OAuth2 ([RFC 6749 §5.2](https://www.rfc-editor.org/rfc/rfc6749#section-5.2)), no en `problem+json`: un campo `error` y, a veces, `error_description`. Responde `401` si el cliente o el secreto no son correctos, y `400` para lo demás. | error | Qué significa | | --- | --- | | `invalid_request` | Falta un campo obligatorio o la petición está mal formada. | | `invalid_client` | El client_id o el client_secret no son correctos, o el cliente se ha dado de baja. | | `unauthorized_client` | El cliente no está autorizado a usar este tipo de concesión. | | `unsupported_grant_type` | grant_type no es client_credentials. | | `invalid_scope` | Se ha pedido un scope que no existe o que el cliente no tiene. | En el resto de la API, un token que falta, ha caducado o se ha revocado da `401`, y un token sin el scope que pide la ruta da `403`. Ver [Errores](https://safeonuba.com/desarrolladores/errores). --- # Centros y zonas > Un centro es una planta del cliente. Sus zonas y puntos de reunión llegan en GeoJSON, y su nivel de privacidad decide qué posiciones ve la API. Página: https://safeonuba.com/desarrolladores/conceptos/centros · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. Un **centro** es una planta, un emplazamiento con su recinto, sus zonas y sus puntos de reunión. Tu cliente puede tener uno o varios, y todo lo demás cuelga de ellos: cada alerta, cada evacuación y cada evento lleva el suyo. ## Centros `GET /v1/sites` devuelve los centros a los que tiene acceso tu cliente. Vale con cualquier scope. | Campo | Qué es | | --- | --- | | `id` | UUID del centro. Es el que usas en los filtros `site_id`. | | `name` | Nombre del centro. | | `location` | Ubicación en texto, o `null`. | | `privacy_mode` | Nivel de privacidad de la planta. Decide qué posiciones ve la API. | | `unveil_min_severity` | En «Solo en emergencia», la gravedad mínima de una alerta a partir de la cual se ve la posición de esa persona. | | privacy_mode | Qué significa | | --- | --- | | `continuous` | La planta ve las posiciones de forma continua, y la API también. | | `on_demand` | «Solo en emergencia», el valor de serie. La posición de una persona solo se ve con una alerta abierta desde `unveil_min_severity`, durante una evacuación o con una solicitud aprobada. | La API obedece la misma regla que el panel. Lo cuenta con detalle [Posiciones](https://safeonuba.com/desarrolladores/conceptos/posiciones). ## Zonas y puntos de reunión `GET /v1/sites/{id}/zones` devuelve las zonas en vigor de un centro. Pide `alerts:read`. Cada zona trae su geometría como polígono **GeoJSON en WGS84** (`geometry.type: "Polygon"`), así que se puede pintar directamente sobre el plano o el mapa de tu sala. | type | Qué significa | | --- | --- | | `work_area` | Zona de trabajo. | | `restricted` | Zona restringida. Entrar genera una alerta `restricted_zone_entry`. | | `forbidden` | Zona prohibida. Entrar genera una alerta `forbidden_zone_entry`. | | `assembly_point` | Punto de reunión de una evacuación. Trae además el objeto `assembly_point`. | | `privacy` | Zona de privacidad, como vestuarios o comedores. Llega con nombre y tipo, pero sin geometría (`geometry: null`). | | `building` | Edificio. | ### Zonas de privacidad Dentro de una zona de privacidad no se registra la posición de nadie. La API da su nombre y su tipo, pero no su polígono. ### Puntos de reunión Las zonas de tipo `assembly_point` llevan además: | Campo | Qué es | | --- | --- | | `serves` | Para qué evacuaciones vale: `general`, `tsunami`, `earthquake`. | | `elevation_m` | Cota en metros, o `null`. | | `elevation_source` | De dónde sale la cota: `dem` o `verified`. | | `vertical` | `true` si es un refugio en altura, dentro de un edificio. | | `capacity` | Aforo, o `null`. | Durante una evacuación, la sala puede descartar puntos de reunión. Eso no cambia las zonas: se ve en la propia evacuación (ver [Evacuaciones y recuento](https://safeonuba.com/desarrolladores/conceptos/evacuaciones#puntos-de-reunion)). ## Filtrar por centro Si tu sala solo cubre una planta, filtra por ella en vez de recibirlo todo: - En las listas, con el parámetro `site_id` (`/v1/alerts`, `/v1/musters`, `/v1/workers`, `/v1/devices`, `/v1/positions/latest`). - En los webhooks, con `filters.site_ids` al crear el destino (ver [Webhooks](https://safeonuba.com/desarrolladores/webhooks#filtros)). Pedir un centro que no es de tu cliente da `403` o `404`, según la ruta. --- # Alertas > Tipos, gravedad y estados de una alerta; la diferencia entre que la sala se haga cargo y que el trabajador vea el aviso; y cómo reconocerla o cerrarla. Página: https://safeonuba.com/desarrolladores/conceptos/alertas · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. Una **alerta** es algo que ha pasado en la planta y que alguien tiene que atender: un SOS, una caída, una entrada donde no se debe, un reloj sin batería o un aviso de terremoto. Es el objeto central de la API. ## El objeto La alerta llega completa en cada webhook (`data.alert`) y es lo que devuelve `GET /v1/alerts/{id}`. Los campos que más se usan: | Campo | Qué es | | --- | --- | | `type`, `severity`, `status` | Qué ha pasado, cómo de grave es y en qué punto de la respuesta está. | | `title` | El título que lee la sala. | | `site`, `zone` | Dónde. `zone` puede ser `null`. | | `worker`, `device` | A quién le ha pasado y con qué reloj. `null` en las alertas de toda la planta. | | `location`, `location_withheld` | Dónde está la persona, si la privacidad de la planta deja verlo. | | `details` | Datos propios de cada tipo. | | `acknowledged`, `resolved`, `assigned_to` | Quién se ha hecho cargo, quién la ha cerrado y a quién está asignada. | | `date_created`, `date_last_modified` | Cuándo se creó y cuándo cambió por última vez. | | `simulated` | `true` si viene de un simulacro o de relojes simulados. | | `panel_url` | Enlace a su ficha en el panel de SafeOnuba. | La lista entera, campo a campo, está en el esquema [`Alert`](https://safeonuba.com/desarrolladores/referencia/esquemas#Alert). ## Tipos | type | Qué significa | | --- | --- | | `sos` | La persona ha pedido ayuda desde el reloj, o la sala lo ha lanzado por ella. | | `fall_detected` | El reloj ha detectado una caída. | | `restricted_zone_entry` | Entrada en una zona restringida. | | `forbidden_zone_entry` | Entrada en una zona prohibida. | | `permit_expired_inside` | El permiso de trabajo ha caducado con la persona todavía dentro de la zona. | | `permit_closed_inside` | El permiso de trabajo se ha cerrado con la persona todavía dentro de la zona. | | `heat_stress` | Estrés térmico: el reloj le ha indicado que pare y descanse. | | `evacuation_help` | Durante una evacuación, la persona dice desde el reloj que no puede salir por su pie o que está ayudando a otra. | | `worker_call_request` | La persona avisa a la sala desde el reloj. Sin cobertura, el reloj graba un aviso de voz que se escucha en el panel. | | `device_offline` | El reloj lleva un tiempo sin comunicar. | | `low_battery` | Al reloj le queda poca batería. | | `earthquake` | Terremoto. Es de toda la planta: no lleva `worker` ni `device`. | | `tsunami_risk` | Riesgo de tsunami. Es de toda la planta: no lleva `worker` ni `device`. | ### Un vehículo cerca no es una alerta Los avisos de vehículo cerca no generan alertas propias. Van dentro de la alerta de un incidente, en dos listas: - `nearby`: quién había a menos de 100 metros cuando saltó una alerta crítica (SOS o caída), personas o vehículos. Da la distancia, nunca la posición, y no cuenta a quien estaba en una zona de privacidad. - `vehicle_warnings`: los avisos de vehículo cerca que recibió esa persona en la ventana del incidente, con la distancia y si los vio en el reloj. ### Detalles de cada tipo `details` cambia según `type`. Por ejemplo, [`SosDetails`](https://safeonuba.com/desarrolladores/referencia/esquemas#SosDetails) dice cómo se pidió el SOS (`trigger`), [`ZoneDetails`](https://safeonuba.com/desarrolladores/referencia/esquemas#ZoneDetails) trae el permiso de trabajo que regía (`permit_reference`) y [`HazardDetails`](https://safeonuba.com/desarrolladores/referencia/esquemas#HazardDetails) la magnitud, la distancia y si el aviso es oficial. Las variantes están en [`AlertDetails`](https://safeonuba.com/desarrolladores/referencia/esquemas#AlertDetails). > **Nunca hay constantes vitales** > > Ni en `details` ni en ningún otro sitio. [`HeatStressDetails`](https://safeonuba.com/desarrolladores/referencia/esquemas#HeatStressDetails) dice lo que se le ha indicado al trabajador (`stop_and_rest`), el índice de calor de la planta y los minutos al sol, nada que salga de su pulso. ## Gravedad | severity | Qué significa | | --- | --- | | `low` | Información. | | `medium` | Aviso. | | `high` | Grave. | | `critical` | Crítica: SOS, caída, «no puedo evacuar». | El filtro `severity` de `GET /v1/alerts` y el `min_severity` de los webhooks son **mínimos**: `high` devuelve `high` y `critical`. ## Estados | status | Qué significa | | --- | --- | | `unacknowledged` | Nadie se ha hecho cargo todavía. | | `acknowledged` | Un operador se ha hecho cargo. | | `resolved` | Cerrada. | - **Reconocer** asigna la alerta a quien la reconoce si nadie la llevaba. Reconocer no es cerrar: una alerta reconocida sigue marcando a la persona en el mapa hasta que se cierra. - **Cerrar** deja constancia de quién, cuándo y con qué nota, en `resolved`. - Una alerta cerrada puede **reabrirse**. Se avisa con el evento `alert.reopened`. > **Dos «visto» distintos** > > `status: "acknowledged"` quiere decir que un operador de la sala se ha hecho cargo. `worker_acknowledged_at` es otra cosa: la hora (del reloj) a la que el trabajador pulsó «Entendido» en su muñeca. Cada uno tiene su evento: `alert.acknowledged` y `alert.worker_acknowledged`. ## Reconocer y cerrar desde tu sala `PUT /v1/alerts/{id}` con el scope `alerts:write`. El cuerpo: | Campo | Obligatorio | Qué es | | --- | --- | --- | | `status` | Sí | `acknowledged` o `resolved`. | | `actor` | Sí | El operador de tu sala: `name` y, si quieres, `external_id`. | | `resolution_reason` | No | Al cerrar, por qué. Hasta 200 caracteres. | | `note` | No | Una nota. Hasta 500 caracteres. | El operador es obligatorio porque «reconocida» mide la respuesta de una persona. En la cronología de la alerta queda «Cliente · Operador (vía integración)». ```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": "Falsa alarma", "note": "Confirmado por radio con el encargado.", "actor": { "name": "E. Ejemplo", "external_id": "OP-117" } }' ``` ```javascript const res = await fetch(`https://sandbox.api.safeonuba.com/v1/alerts/${alertaId}`, { method: 'PUT', headers: { Authorization: `Bearer ${await tokenSafeOnuba()}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 'resolved', resolution_reason: 'Falsa alarma', note: 'Confirmado por radio con el encargado.', actor: { name: operador.nombre, external_id: operador.id }, }), }); if (res.status === 409) { // Ya estaba cerrada, o la transición no es posible: nada que hacer. } else if (!res.ok) { throw new Error(`${res.status}: ${await res.text()}`); } ``` ```python res = requests.put( f"https://sandbox.api.safeonuba.com/v1/alerts/{alerta_id}", headers={"Authorization": f"Bearer {token_safeonuba()}"}, json={ "status": "resolved", "resolution_reason": "Falsa alarma", "note": "Confirmado por radio con el encargado.", "actor": {"name": operador.nombre, "external_id": operador.id}, }, timeout=10, ) if res.status_code != 409: # 409: ya estaba cerrada res.raise_for_status() ``` ```csharp var cuerpo = JsonContent.Create(new { status = "resolved", resolution_reason = "Falsa alarma", note = "Confirmado por radio con el encargado.", actor = new { name = operador.Nombre, external_id = operador.Id }, }); var res = await http.PutAsync($"https://sandbox.api.safeonuba.com/v1/alerts/{alertaId}", cuerpo); if (res.StatusCode != System.Net.HttpStatusCode.Conflict) res.EnsureSuccessStatusCode(); ``` La respuesta es la alerta ya actualizada. Si la transición no es posible —por ejemplo, cerrar o reconocer una alerta ya cerrada— la API responde `409`. ## Consultar alertas `GET /v1/alerts` devuelve las alertas de la más reciente a la más antigua, paginadas. Filtros: | Parámetro | Qué filtra | | --- | --- | | `site_id` | Un centro. | | `type` | Un tipo. | | `status` | Un estado. | | `severity` | Gravedad mínima. | | `since`, `until` | Intervalo de fechas, en ISO 8601. | | `limit`, `cursor` | Paginación (ver [Paginación](https://safeonuba.com/desarrolladores/paginacion)). | Para seguir las alertas en tiempo real no consultes en bucle: usa [webhooks](https://safeonuba.com/desarrolladores/webhooks). La lista sirve para cargar el estado al arrancar y para buscar. ## Nombres de los trabajadores Los nombres son opcionales por contrato. Si tu contrato no los incluye, `worker.name` no aparece y `title` se compone con el tipo, la zona y el identificador de empresa (`external_id`), sin nombre. Ver [Trabajadores y relojes](https://safeonuba.com/desarrolladores/conceptos/trabajadores-y-relojes). --- # Evacuaciones y recuento > Tipos, estados y fases de una evacuación, el recuento por persona y por punto de reunión, los puntos descartados y la lista de quién falta. Página: https://safeonuba.com/desarrolladores/conceptos/evacuaciones · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. Una **evacuación** (`muster` en la API) es el recuento de una planta cuando hay que salir: quién ha llegado a un punto de reunión válido, quién pide ayuda y quién falta. Es lo que más importa que llegue bien a tu sala, y por eso sus eventos salen por delante de casi todo lo demás. > **La API no abre evacuaciones** > > En v1 una evacuación empieza siempre en el panel de SafeOnuba: el sistema puede proponerla, pero la decide una persona. Tu sala la recibe en cuanto empieza (`muster.started`) y la sigue hasta que termina. ## Tipo, estado y fase | kind | Qué significa | | --- | --- | | `general` | Evacuación general. | | `tsunami` | Por tsunami. Puede traer `deadline`, la hora límite. | | `earthquake` | Por terremoto. | El tipo puede cambiar durante la evacuación (`muster.kind_changed`), y con él los puntos de reunión que valen. | status | Qué significa | | --- | --- | | `active` | En marcha. | | `completed` | Terminada. | | `cancelled` | Cancelada. | | phase | Qué significa | | --- | --- | | `evacuating` | Se está evacuando. | | `inspecting` | El recuento está completo y la sala revisa la planta. Los relojes dicen «no vuelvas hasta nuevo aviso». | Otros campos útiles: - `reason`: el motivo, en texto. - `origin`: de dónde viene la emergencia (coordenadas, radio y zona). `null` en una evacuación sin origen, como un simulacro general. - `deadline`: en un tsunami, la hora límite y de dónde sale: `official` (un aviso oficial) o `plan_estimate` (el techo del plan de la zona). Nunca es un cálculo físico. ## El recuento `GET /v1/musters/{id}` devuelve la evacuación con su recuento en `totals`, calculado en el servidor con la misma regla que el panel y el cierre: posición precisa dentro de un punto válido, o llegada declarada a uno. Cada persona está en uno de estos estados: | Estado | Qué quiere decir | | --- | --- | | `safe` | A salvo en un punto de reunión válido. | | `help` | Pide ayuda: atrapada (`trapped`), herida (`injured`) o ayudando a otra persona (`helping`). | | `wrong_point` | En un punto de reunión que no vale para este tipo de evacuación, o que la sala ha descartado. | | `pending` | Todavía no ha llegado. | | `no_signal` | Su reloj no comunica. | | `not_worn` | No lleva el reloj puesto. | `totals` trae `expected` (cuántas personas se esperan) y el número de personas en cada estado. > **Una evacuación cerrada guarda menos** > > Al cerrar solo se registran los llegados y el total. En una evacuación terminada, el resto de cifras de `totals` y el `count` de cada punto salen `null`. ## Puntos de reunión `assembly_points` lista los destinos del tipo de evacuación, descartados incluidos, con cuántas personas hay a salvo en cada uno (`count`). La sala puede **descartar** un punto durante la evacuación, por ejemplo porque queda dentro del radio de la emergencia o a sotavento, y también rehabilitarlo. Un punto descartado tiene `status: "excluded"` y un objeto `excluded` con: - `reason`: por qué; - `recommended`: si lo había recomendado el sistema (por radio o por viento). Quien descarta es siempre una persona; - `at` y `by`: cuándo y quién. Cada cambio de destinos sube `targets_revision` y emite `muster.targets_changed`. Quien estaba en un punto descartado pasa a `wrong_point`. ## Quién falta `GET /v1/musters/{id}/missing` devuelve las personas que no están a salvo, ordenadas por urgencia: primero quien pide ayuda o necesita rescate, después quien está en un punto que no sirve, sin señal, sin el reloj puesto y saliendo. - `needs_rescue`: tiene un SOS o una caída abierta y no ha llegado. No puede salir por su pie. - `help_reason`: `trapped`, `injured` o `helping`. - `assembly_point`: el punto donde está o dijo estar, con `valid: false` si no sirve. - `location`: la última posición, solo con el scope `positions:read` y si la privacidad de la planta la deja ver. Si no, `location_withheld: true`. ## Eventos de una evacuación | Evento | Cuándo | | --- | --- | | `muster.started` | Empieza la evacuación. | | `muster.kind_changed` | Cambia el tipo. | | `muster.phase_changed` | Cambia la fase (`evacuating`, `inspecting`). | | `muster.targets_changed` | La sala ha descartado o rehabilitado un punto de reunión. | | `muster.updated` | El recuento ha cambiado. Se comprueba cada 5 segundos y solo se emite si cambia. | | `muster.worker_missing` | Falta una persona. | | `muster.ended` | Termina. | Una persona que pide ayuda durante la evacuación genera además una alerta `evacuation_help` (`alert.created`), que lleva el `muster_id` en sus detalles. Cada evento trae la evacuación completa en `data.muster`, así que tu sala puede repintar el banner con el último que le llegue. Como el orden de entrega no está garantizado, quédate con el más reciente por `created_at` (ver [Webhooks](https://safeonuba.com/desarrolladores/webhooks#orden)). ## Probarlo sin evacuar a nadie El sandbox tiene un escenario que recorre una evacuación entera en 3 minutos: llegadas, un punto descartado, una petición de ayuda y el cierre. Ver [Sandbox](https://safeonuba.com/desarrolladores/sandbox#evacuacion-completa). --- # Trabajadores y relojes > Cómo identifica la API a cada persona y a su reloj: identificador de empresa, nombre opcional por contrato, batería, si lo lleva puesto y última conexión. Página: https://safeonuba.com/desarrolladores/conceptos/trabajadores-y-relojes · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. Cada persona lleva un reloj. La API los expone por separado —trabajadores y relojes— y los relaciona entre sí. Los dos piden el scope `workers:read`. ## Trabajadores `GET /v1/workers` y `GET /v1/workers/{id}`. | Campo | Qué es | | --- | --- | | `id` | UUID del trabajador en SafeOnuba. | | `external_id` | Su código en tu empresa, el de tu sistema de personal. `null` si no se ha dado. | | `name` | Nombre y apellidos. **Solo si el contrato lo incluye**; si no, la propiedad no aparece. | | `contractor` | La contrata para la que trabaja, o `null`. | | `role` | Su puesto, o `null`. | | `is_active` | Si está dado de alta. | | `device` | El reloj que tiene asignado. | | `date_last_seen` | Lo más reciente entre su última posición y el último contacto del reloj. | ### Sin nombres Los nombres de los trabajadores son opcionales por contrato. Sin ellos, la API identifica a cada persona por su `external_id`, el mismo código que ya usa tu empresa. Para cruzar con tu sistema de personal o de control de accesos, usa ese campo, que no depende de que el contrato incluya nombres. El mismo criterio se aplica en todas partes: en `worker` dentro de una alerta, en la lista de quién falta y en las posiciones. ### Lo que ve tu cliente La API ve lo mismo que una persona del panel con esos permisos, incluido el alcance por contrata. Un trabajador que tu identidad no ve responde `404`, como si no existiera. ## Relojes `GET /v1/devices` devuelve los relojes con su estado. | Campo | Qué es | | --- | --- | | `id`, `serial` | Identificador y número de serie. | | `model` | Modelo, o `null`. | | `status` | Estado del reloj en el inventario. | | `battery` | Batería en porcentaje, o `null`. | | `worn` | Si lo lleva puesto. `null` si no se sabe, porque el reloj no tiene sensor de muñeca. | | `date_last_seen` | Último contacto. | | `worker` | A quién está asignado. | | status | Qué significa | | --- | --- | | `active` | En uso. | | `inactive` | Fuera de uso. | | `maintenance` | En mantenimiento. | | `lost` | Perdido. | Un reloj que deja de comunicar o se queda sin batería genera una alerta (`device_offline`, `low_battery`). No hace falta consultar esta lista en bucle para enterarse: llegan por webhook como cualquier otra alerta. ## Nunca hay constantes vitales Ni el trabajador ni el reloj traen pulso, temperatura ni ninguna otra constante, sueltas ni agregadas. Ver [Privacidad y seguridad](https://safeonuba.com/desarrolladores/privacidad-y-seguridad). --- # Posiciones > Cuándo entrega la API una posición, qué significa location_withheld, cómo se representan las zonas de privacidad y qué precisión trae cada punto. Página: https://safeonuba.com/desarrolladores/conceptos/posiciones · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. Una posición es dónde está una persona. Es el dato más delicado de la API, y por eso sigue exactamente la misma regla que el panel: si una persona del panel con esos permisos no la ve, la API tampoco. ## Cuándo se ve una posición Lo decide el nivel de privacidad de cada centro (`privacy_mode` en `GET /v1/sites`): - **«Solo en emergencia»** (`on_demand`), el valor de serie. La posición de una persona solo se ve cuando tiene una alerta abierta con gravedad igual o superior a `unveil_min_severity`, durante una evacuación o con una solicitud aprobada en el panel. - **Continuo** (`continuous`). La planta ha elegido ver las posiciones de forma continua. Dentro de una zona de privacidad (vestuarios, comedores) no se registra la posición de nadie, con cualquier nivel. ## En una alerta: location_withheld Cada alerta trae `location` y `location_withheld`: - Si la privacidad deja ver la posición, `location` la trae y `location_withheld` es `false`. - Si la posición existe pero no se entrega —en «Solo en emergencia», una alerta por debajo de la gravedad que destapa—, `location_withheld` es `true`. Tampoco se ve en el panel. Tu sala tiene que contemplar los dos casos. Una alerta sin posición no es un error: es la planta protegiendo a su gente. Para ese caso, `panel_url` lleva a la ficha de la alerta en el panel. ## Últimas posiciones `GET /v1/positions/latest` (scope `positions:read`) devuelve las posiciones que la privacidad deja ver **en ese instante**. En «Solo en emergencia», quien no tiene nada abierto no aparece. | Campo | Qué es | | --- | --- | | `device_id` | El reloj. | | `worker` | La persona. | | `location` | La posición. | | `in_vehicle` | Si va en un vehículo. | Cada posición que se sirve con una alerta detrás queda registrada en esa alerta, en «Quién ha visto su posición». ## El objeto location | Campo | Qué es | | --- | --- | | `latitude`, `longitude` | Coordenadas WGS84. | | `uncertainty_m` | Radio de incertidumbre en metros. `null` si no se conoce, que no es lo mismo que tenerla perfecta. | | `source` | De dónde sale el punto. | | `masked` | `true` si el punto es el representativo de una zona de privacidad y no el de la persona. | | `position_date_utc` | Cuándo se tomó. | | `height` | Altura sobre el suelo por barómetro, con su incertidumbre, edificio y planta, cuando se puede afirmar. Si no, `null`. | | source | Qué significa | | --- | --- | | `gps` | GPS del reloj. | | `network` | Posición por red. | | `zone` | La persona estaba en una zona de privacidad: el punto es el de la zona, no el suyo. | Pinta siempre el radio de `uncertainty_m` junto al punto: un punto sin radio da una precisión que no tiene. ## Distancias sin posición En una alerta crítica, `nearby` dice quién había a menos de 100 metros y a qué distancia, **nunca dónde**. Quien estaba en una zona de privacidad no cuenta. Ver [Alertas](https://safeonuba.com/desarrolladores/conceptos/alertas#un-vehiculo-cerca-no-es-una-alerta). ## No sigas a nadie en bucle Consultar `GET /v1/positions/latest` cada pocos segundos no te da más que las alertas: en «Solo en emergencia» solo aparecen las personas que ya tienen una alerta abierta, y esa alerta ya te llega por webhook con la posición dentro. Además, cada consulta cuenta para tus [límites de uso](https://safeonuba.com/desarrolladores/limites). --- # Webhooks > Crea un destino, entiende el formato de cada envío, responde a tiempo, deduplica, filtra por centro o gravedad y envía un evento de prueba. Página: https://safeonuba.com/desarrolladores/webhooks · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. Con un webhook, SafeOnuba avisa a tu servidor en cuanto pasa algo: una alerta nueva, un operador que se hace cargo, un cambio en el recuento de una evacuación. Tu sala no tiene que preguntar, y la alerta le llega en uno o dos segundos. ## Crear un destino Un destino es una URL tuya y los eventos que quieres recibir en ella. Se crea con `POST /v1/webhooks` y el scope `webhooks:manage`. | Campo | Obligatorio | Qué es | | --- | --- | --- | | `url` | Sí | Tu receptor. Solo `https`, y en una dirección pública. | | `event_types` | Sí | Los eventos: grupos enteros (`alert.*`, `muster.*`) o tipos concretos (`alert.created`). | | `filters` | No | Qué parte de esos eventos: por centro, gravedad o contrata. Ver [Filtros](https://safeonuba.com/desarrolladores/webhooks#filtros). | ```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 tokenSafeOnuba()}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ url: 'https://sala.cliente.example/safeonuba', event_types: ['alert.*', 'muster.*'], filters: { min_severity: 'high' }, }), }); const destino = await res.json(); // destino.secret solo llega esta vez: guárdalo ya. ``` ```python res = requests.post( "https://sandbox.api.safeonuba.com/v1/webhooks", headers={"Authorization": f"Bearer {token_safeonuba()}"}, json={ "url": "https://sala.cliente.example/safeonuba", "event_types": ["alert.*", "muster.*"], "filters": {"min_severity": "high"}, }, timeout=10, ) res.raise_for_status() destino = res.json() # destino["secret"] solo llega esta vez: guárdalo ya. ``` ```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 destino = await res.Content.ReadFromJsonAsync(); // destino.GetProperty("secret") solo llega esta vez: guárdalo ya. ``` La respuesta es el destino con un campo más, `secret`, que empieza por `whsec_`. **Solo se enseña esta vez.** Es el secreto con el que [verificas la firma](https://safeonuba.com/desarrolladores/webhooks/verificar-firma) de cada envío. ## Formato de un envío Cada evento es un `POST` a tu URL con el cuerpo en JSON y estas cabeceras: | Cabecera | Qué lleva | | --- | --- | | `X-SafeOnuba-Event-Id` | El `id` del evento. | | `X-SafeOnuba-Timestamp` | Cuándo se firmó, en segundos Unix. | | `X-SafeOnuba-Signature` | `v1=`. Durante una rotación del secreto, dos firmas separadas por coma. | | `User-Agent` | `SafeOnuba-Webhooks/2026-09-24` | El cuerpo es siempre el mismo sobre: | Campo | Qué es | | --- | --- | | `id` | Identificador del evento. Es el que usas para [deduplicar](https://safeonuba.com/desarrolladores/webhooks#duplicados). | | `type` | El tipo de evento (ver [Eventos](https://safeonuba.com/desarrolladores/eventos#tipos-de-evento)). | | `api_version` | `"2026-09-24"`. | | `created_at` | Cuándo ocurrió, en ISO 8601 UTC. | | `organization_id` | La empresa del centro. `null` en los eventos de prueba. | | `site_id` | El centro. | | `data` | `{ "alert": … }` o `{ "muster": … }`: el objeto completo, tal como lo devolvería el `GET` en ese instante. | ```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" } } } ``` Como `data` trae el objeto entero, normalmente no hace falta llamar a la API al recibir un evento: tienes todo lo que tu sala necesita para pintarlo. ## Responder a tiempo Un envío cuenta como entregado cuando tu receptor responde **cualquier `2xx` en menos de 5 segundos**. Las redirecciones no se siguen: un `301` o un `302` es un fallo. Por eso tu receptor debe hacer lo mínimo: verificar la firma, dejar el evento en una cola (una tabla, Redis, RabbitMQ, lo que ya uses) y responder. Procesa después, fuera de la petición. Si tu sistema de sala tarda o se cae, la cola aguanta y SafeOnuba no reintenta en balde. ## Reintentos Si tu receptor no responde `2xx` a tiempo, el envío se reintenta a los **10 s, 30 s, 2 min, 10 min, 30 min y 1 h**, y después **cada hora hasta 24 horas** desde el evento. Si una entrega se da por perdida, avisamos por correo al contacto técnico de tu empresa. El evento sigue disponible en `GET /v1/events` durante 30 días (ver [Recuperar lo perdido](https://safeonuba.com/desarrolladores/eventos#recuperar-lo-perdido)). Un destino que falla **no se desactiva solo**: si lo hiciera, dejaría de llegar el siguiente SOS sin que nadie lo hubiera decidido. En su lugar, `failing_since` dice desde cuándo lleva fallando. Vigílalo con `GET /v1/webhooks`. ## Duplicados La entrega es **«al menos una vez»**: un mismo evento puede llegar más de una vez, por ejemplo si tu receptor lo procesó pero la respuesta no llegó a tiempo. Deduplica por el `id` del evento (también viene en `X-SafeOnuba-Event-Id`). La forma más sencilla es una tabla con el `id` como clave: ```sql CREATE TABLE eventos_safeonuba ( id uuid PRIMARY KEY, recibido_en timestamptz NOT NULL DEFAULT now() ); -- Devuelve una fila si el evento es nuevo, ninguna si ya se había recibido. INSERT INTO eventos_safeonuba (id) VALUES ($1) ON CONFLICT (id) DO NOTHING RETURNING id; ``` ## Orden **El orden de entrega no está garantizado.** Lo urgente sale primero: SOS, caídas, «no puedo evacuar» y evacuaciones adelantan al resto. Y un reintento puede llegar después de un evento más nuevo. Cada evento trae el objeto completo, así que la regla es quedarse con el más reciente y descartar lo que llegue más viejo: - Para ordenar los eventos entre sí, usa su `created_at`. - Para una alerta, compara `date_last_modified`: si lo que llega es más antiguo que lo que ya tienes, descártalo. - Si detectas un hueco, por ejemplo tras un corte, rellénalo con `GET /v1/events`. ```javascript const ultimaVersion = new Map(); // id de la alerta → date_last_modified function aplicarAlerta(alerta) { const conocida = ultimaVersion.get(alerta.id); if (conocida && Date.parse(conocida) >= Date.parse(alerta.date_last_modified)) return; // más vieja ultimaVersion.set(alerta.id, alerta.date_last_modified); pintarEnSala(alerta); } ``` ## Filtros Con `filters` recibes solo una parte de los eventos que has pedido: | Filtro | Qué hace | | --- | --- | | `site_ids` | Solo esos centros. | | `min_severity` | Solo alertas de esa gravedad o más (`low`, `medium`, `high`, `critical`). | | `contractors` | Solo lo que afecta a esas contratas. | Puedes tener varios destinos con filtros distintos: por ejemplo, las alertas críticas de todas las plantas a la sala central y todo lo de una planta a su propia sala. ## Enviar uno de prueba `POST /v1/webhooks/{id}/test` manda a ese destino un `alert.created` marcado `simulated: true`, firmado como uno real. Responde `202` al ponerlo en cola; el resultado se ve en el destino, en `failing_since`. Para probar el recorrido completo, con alertas y evacuaciones de verdad hechas por relojes simulados, usa el [sandbox](https://safeonuba.com/desarrolladores/sandbox). ## Rotar el secreto `POST /v1/webhooks/{id}/rotate-secret` da un secreto nuevo. El anterior sigue firmando 24 horas, y durante ese tiempo cada envío lleva las dos firmas. Cómo cambiar de secreto sin perder ningún evento: [Verificar la firma](https://safeonuba.com/desarrolladores/webhooks/verificar-firma#rotar-el-secreto). ## Gestionar los destinos - `GET /v1/webhooks`: tus destinos, con sus eventos, filtros, `is_active` y `failing_since`. - `DELETE /v1/webhooks/{id}`: borra un destino. ## Webhooks o consultas Usa webhooks para enterarte de lo que pasa, y la API para lo demás: cargar el estado al arrancar, la lista de quién falta, reconocer una alerta. Consultar en bucle llega más tarde que un webhook y gasta tus [límites de uso](https://safeonuba.com/desarrolladores/limites). --- # Verificar la firma de un webhook > Cómo comprobar que un envío viene de SafeOnuba: HMAC-SHA256 sobre el cuerpo en bruto, comparación en tiempo constante, ventana de 5 minutos y rotación del secreto. Página: https://safeonuba.com/desarrolladores/webhooks/verificar-firma · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. Tu receptor está en una URL pública, así que cualquiera puede mandarle un `POST`. La firma es lo que demuestra que el envío viene de SafeOnuba y que nadie lo ha tocado por el camino. **No proceses ningún evento sin verificarla.** ## Cómo se firma Cada envío lleva dos cabeceras: - `X-SafeOnuba-Timestamp`: el momento de la firma, en segundos Unix. - `X-SafeOnuba-Signature`: `v1=` seguido de la firma en hexadecimal. La firma es un HMAC-SHA256, con el secreto del destino (el `whsec_…` que te dio la API al crearlo) como clave, sobre el timestamp, un punto y el cuerpo **tal cual llegó**: ```text v1=hex( HMAC_SHA256( secreto, timestamp + "." + cuerpo_en_bruto ) ) ``` ## Qué tiene que hacer tu receptor 1. **Leer el cuerpo en bruto**, como bytes, antes de que ningún framework lo convierta en JSON. Si lo parseas y lo vuelves a serializar, cambian los espacios o el orden de las claves y la firma deja de cuadrar. 2. **Rechazar si el timestamp se desvía más de 5 minutos** de tu reloj, en cualquier sentido. Evita que alguien reenvíe un evento antiguo capturado. Mantén el reloj del servidor sincronizado por NTP. 3. **Calcular la firma** con tu secreto sobre `timestamp + "." + cuerpo`. 4. **Compararla en tiempo constante** con cada firma de la cabecera. Una comparación normal (`==`) tarda distinto según cuántos caracteres coinciden, y eso filtra información. 5. **Aceptar si cuadra cualquiera** de las firmas de la cabecera. Durante una rotación del secreto llegan dos, separadas por coma. Si la firma no es válida, responde `401` y descarta el cuerpo. ## Receptor completo Los tres ejemplos hacen lo mismo: verifican, dejan el evento en tu cola (`encolar`, que pones tú) y responden `204` enseguida. ```javascript import { createHmac, timingSafeEqual } from 'node:crypto'; import { createServer } from 'node:http'; const SECRETO = process.env.SAFEONUBA_WEBHOOK_SECRET; // whsec_… const TOLERANCIA_S = 5 * 60; export function firmaValida(cuerpo, marca, cabecera, secreto = SECRETO) { if (!cuerpo || !marca || !cabecera) return false; // 1. El timestamp, a menos de 5 minutos de nuestro reloj. const t = Number(marca); if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > TOLERANCIA_S) return false; // 2. La firma esperada, sobre el cuerpo en bruto (un Buffer). const esperada = createHmac('sha256', secreto).update(`${marca}.`).update(cuerpo).digest(); // 3. Vale cualquiera de las firmas de la cabecera, comparadas en tiempo constante. return cabecera.split(',').some((parte) => { const [version, hex] = parte.trim().split('='); if (version !== 'v1' || !/^[0-9a-f]{64}$/i.test(hex ?? '')) return false; return timingSafeEqual(Buffer.from(hex, 'hex'), esperada); }); } createServer((req, res) => { if (req.method !== 'POST' || req.url !== '/safeonuba') { res.writeHead(404).end(); return; } const trozos = []; req.on('data', (trozo) => trozos.push(trozo)); req.on('end', () => { const cuerpo = Buffer.concat(trozos); // en bruto: nada de JSON.parse antes de verificar const valida = firmaValida( cuerpo, req.headers['x-safeonuba-timestamp'], req.headers['x-safeonuba-signature'], ); if (!valida) { res.writeHead(401).end(); return; } encolar(JSON.parse(cuerpo.toString('utf8'))); // se procesa después, fuera de la petición res.writeHead(204).end(); }); }).listen(8080); ``` ```python import hashlib import hmac import os import time from flask import Flask, abort, request SECRETO = os.environ["SAFEONUBA_WEBHOOK_SECRET"].encode() # whsec_… TOLERANCIA_S = 5 * 60 app = Flask(__name__) def firma_valida(cuerpo: bytes, marca: str | None, cabecera: str | None) -> bool: if not marca or not cabecera: return False # 1. El timestamp, a menos de 5 minutos de nuestro reloj. try: t = int(marca) except ValueError: return False if abs(time.time() - t) > TOLERANCIA_S: return False # 2. La firma esperada, sobre el cuerpo en bruto. esperada = hmac.new(SECRETO, marca.encode() + b"." + cuerpo, hashlib.sha256).hexdigest() # 3. Vale cualquiera de las firmas de la cabecera, comparadas en tiempo constante. for parte in cabecera.split(","): version, _, firma = parte.strip().partition("=") if version == "v1" and hmac.compare_digest(firma.lower(), esperada): return True return False @app.post("/safeonuba") def recibir(): cuerpo = request.get_data() # en bruto, antes de tocar request.json if not firma_valida( cuerpo, request.headers.get("X-SafeOnuba-Timestamp"), request.headers.get("X-SafeOnuba-Signature"), ): abort(401) encolar(request.get_json()) # se procesa después, fuera de la petición return "", 204 ``` ```csharp using System.Security.Cryptography; using System.Text; using System.Text.Json; var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); var secreto = Encoding.UTF8.GetBytes(builder.Configuration["SAFEONUBA_WEBHOOK_SECRET"]!); // whsec_… app.MapPost("/safeonuba", async (HttpRequest req) => { // En bruto: se lee el cuerpo entero antes de deserializar nada. using var ms = new MemoryStream(); await req.Body.CopyToAsync(ms); var cuerpo = ms.ToArray(); if (!FirmaValida(cuerpo, req.Headers["X-SafeOnuba-Timestamp"], req.Headers["X-SafeOnuba-Signature"], secreto)) return Results.Unauthorized(); Encolar(JsonDocument.Parse(cuerpo)); // se procesa después, fuera de la petición return Results.NoContent(); }); app.Run(); static bool FirmaValida(byte[] cuerpo, string? marca, string? cabecera, byte[] secreto) { if (string.IsNullOrEmpty(marca) || string.IsNullOrEmpty(cabecera)) return false; // 1. El timestamp, a menos de 5 minutos de nuestro reloj. if (!long.TryParse(marca, out var t)) return false; if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > 5 * 60) return false; // 2. La firma esperada, sobre el cuerpo en bruto. var firmado = Encoding.UTF8.GetBytes(marca + ".").Concat(cuerpo).ToArray(); var esperada = HMACSHA256.HashData(secreto, firmado); // 3. Vale cualquiera de las firmas de la cabecera, comparadas en tiempo constante. foreach (var parte in cabecera.Split(',')) { var trozos = parte.Trim().Split('=', 2); if (trozos.Length != 2 || trozos[0] != "v1") continue; byte[] recibida; try { recibida = Convert.FromHexString(trozos[1]); } catch (FormatException) { continue; } if (CryptographicOperations.FixedTimeEquals(recibida, esperada)) return true; } return false; } ``` > **Si usas un framework, desactiva el parseo en esta ruta** > > Express, Fastify, Django REST o ASP.NET con `[FromBody]` convierten el cuerpo en un objeto antes de que llegue a tu código, y a partir de ahí ya no puedes verificar. En Express, por ejemplo, monta esta ruta con `express.raw({ type: 'application/json' })` en vez de `express.json()`. ## Rotar el secreto Rota el secreto si crees que se ha filtrado, cuando se va alguien que lo conocía o simplemente cada cierto tiempo. No se corta nada: 1. Llama a `POST /v1/webhooks/{id}/rotate-secret`. La respuesta trae el secreto nuevo (`secret`, solo esta vez) y hasta cuándo sigue firmando el anterior (`previous_valid_until`). 2. Durante las **24 horas** siguientes, cada envío lleva **dos firmas** en `X-SafeOnuba-Signature`, una con cada secreto, separadas por coma: ```text v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd,v1=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39 ``` 3. Cambia el secreto de tu receptor por el nuevo en cualquier momento de esas 24 horas. Como el código de arriba acepta si cuadra cualquiera de las firmas, funciona igual antes y después del cambio. 4. Pasadas las 24 horas, el anterior deja de firmar. Una segunda rotación retira el primer secreto: nunca hay más de dos a la vez. ## Errores frecuentes - **Verificar sobre el JSON reserializado.** La firma se calcula sobre los bytes exactos que llegaron. - **Comparar con `==`.** Usa `timingSafeEqual`, `hmac.compare_digest` o `CryptographicOperations.FixedTimeEquals`. - **Ignorar el timestamp.** Sin la ventana de 5 minutos, un evento capturado se puede reenviar cuando se quiera. - **Quedarse solo con la primera firma.** En una rotación llegan dos, y la que cuadra con tu secreto puede ser la segunda. - **Responder después de procesar.** Si tu sala tarda más de 5 segundos, el envío cuenta como fallido y se reintenta. Verifica, encola y responde. --- # Eventos > Los tipos de evento, el sobre común y cómo recuperar los que se perdieron con /v1/events, que devuelve exactamente lo mismo que se envió por webhook. Página: https://safeonuba.com/desarrolladores/eventos · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. Un evento es un cambio: una alerta que se crea, un operador que la reconoce, un recuento que sube. Los mismos eventos llegan por [webhook](https://safeonuba.com/desarrolladores/webhooks) y se pueden volver a pedir con `GET /v1/events`. ## Tipos de evento | type | Qué significa | | --- | --- | | `alert.created` | Se ha creado una alerta. | | `alert.acknowledged` | Un operador se ha hecho cargo de la alerta, desde el panel o desde tu sala. | | `alert.resolved` | La alerta se ha cerrado. | | `alert.reopened` | Una alerta cerrada se ha vuelto a abrir. | | `alert.worker_acknowledged` | El trabajador ha visto el aviso en su reloj (`worker_acknowledged_at`). | | `muster.started` | Ha empezado una evacuación. | | `muster.kind_changed` | Ha cambiado el tipo de la evacuación (general, tsunami, terremoto). | | `muster.phase_changed` | Ha cambiado la fase: `evacuating` o `inspecting`. | | `muster.targets_changed` | La sala ha descartado o rehabilitado un punto de reunión. | | `muster.updated` | El recuento ha cambiado. Se comprueba cada 5 segundos y solo se emite si cambia. | | `muster.worker_missing` | Falta una persona. | | `muster.ended` | La evacuación ha terminado. | Al crear un destino puedes suscribirte a grupos enteros (`alert.*`, `muster.*`) o a tipos concretos. ## El sobre Todos los eventos tienen la misma forma: | Campo | Qué es | | --- | --- | | `id` | Identificador del evento, para deduplicar. | | `type` | Uno de los tipos de arriba. | | `api_version` | `"2026-09-24"`. | | `created_at` | Cuándo ocurrió. | | `organization_id` | La empresa del centro. `null` en los eventos de prueba. | | `site_id` | El centro. | | `data` | `{ "alert": … }` en los `alert.*` y `{ "muster": … }` en los `muster.*`. | Lo que va dentro de `data` es **el objeto completo tal como lo devolvería el `GET` en ese instante**: la alerta entera, o la evacuación con su recuento y sus puntos de reunión. No es un aviso de «algo ha cambiado, ven a mirarlo»; es el estado. ```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" } } } ``` El esquema completo está en la referencia: [`Event`](https://safeonuba.com/desarrolladores/referencia/esquemas#Event). ## Recuperar lo perdido Si tu receptor ha estado caído, o quieres comprobar que no te falta nada, `GET /v1/events` devuelve los eventos de los últimos 30 días **en orden de publicación**, del más antiguo al más reciente. Son exactamente los mismos bytes que se enviaron por webhook, así que los procesas con el mismo código. | Parámetro | Qué es | | --- | --- | | `since` | **Obligatorio.** Desde cuándo, como mucho 30 días atrás. | | `type` | Solo un tipo de evento. | | `limit`, `cursor` | Paginación (ver [Paginación](https://safeonuba.com/desarrolladores/paginacion)). | Vale con cualquier scope. ```javascript // Se pone al día desde el último evento procesado. async function recuperar(desde) { let cursor = null; do { const url = new URL('https://sandbox.api.safeonuba.com/v1/events'); url.searchParams.set('since', desde); url.searchParams.set('limit', '500'); if (cursor) url.searchParams.set('cursor', cursor); const res = await fetch(url, { headers: { Authorization: `Bearer ${await tokenSafeOnuba()}` } }); if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`); const pagina = await res.json(); for (const evento of pagina.data) await procesar(evento); // el mismo código que el webhook; deduplica por id cursor = pagina.next_cursor; } while (cursor); } ``` ```python def recuperar(desde: str) -> None: """Se pone al día desde el último evento procesado.""" cursor = None while True: params = {"since": desde, "limit": 500} if cursor: params["cursor"] = cursor res = requests.get( "https://sandbox.api.safeonuba.com/v1/events", headers={"Authorization": f"Bearer {token_safeonuba()}"}, params=params, timeout=30, ) res.raise_for_status() pagina = res.json() for evento in pagina["data"]: procesar(evento) # el mismo código que el webhook; deduplica por id cursor = pagina["next_cursor"] if not cursor: break ``` ```csharp async Task Recuperar(string desde) { string? cursor = null; do { var url = $"https://sandbox.api.safeonuba.com/v1/events?since={Uri.EscapeDataString(desde)}&limit=500" + (cursor is null ? "" : $"&cursor={Uri.EscapeDataString(cursor)}"); var res = await http.GetAsync(url); res.EnsureSuccessStatusCode(); var pagina = await res.Content.ReadFromJsonAsync(); foreach (var evento in pagina.GetProperty("data").EnumerateArray()) await Procesar(evento); // el mismo código que el webhook; deduplica por id var siguiente = pagina.GetProperty("next_cursor"); cursor = siguiente.ValueKind == JsonValueKind.Null ? null : siguiente.GetString(); } while (cursor is not null); } ``` ### Cuándo usarlo - **Al arrancar tu receptor** tras una parada, desde el `created_at` del último evento que procesaste. - **Tras un aviso de entrega perdida**: si un envío agota sus reintentos, avisamos por correo a tu contacto técnico. El evento sigue en `/v1/events`. - **Para rellenar huecos** si ves que te falta un paso intermedio. Como es la misma entrega «al menos una vez», deduplica por `id`: lo más normal es que parte de lo que recuperes ya lo hubieras recibido. > **No lo uses como sustituto de los webhooks** > > Consultar `/v1/events` en bucle llega más tarde que un webhook y gasta tus [límites de uso](https://safeonuba.com/desarrolladores/limites). Es la red, no el camino. --- # Sandbox: integrar sin relojes > Lanza escenarios de SOS, caída, estrés térmico o una evacuación completa con relojes simulados y recibe lo mismo que llegaría de una planta real. Página: https://safeonuba.com/desarrolladores/sandbox · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. El sandbox (`https://sandbox.api.safeonuba.com`) es un entorno completo con **relojes simulados**. Sirve para integrar sin tener relojes, sin estar en la planta y sin molestar a nadie. Un escenario no se inventa alertas: los relojes simulados de un centro de sandbox mandan sus hechos y el motor de siempre decide. Lo que te llega —webhook, `GET`, recuento— es lo mismo que llegaría de un reloj de verdad, con una diferencia: todo lleva `simulated: true`. ## Lanzar un escenario `POST /v1/sandbox/scenarios`, con cualquier token válido: | Campo | Obligatorio | Qué es | | --- | --- | --- | | `scenario` | Sí | `sos`, `fall`, `heat_stress` o `evacuation`. | | `site_id` | No | El centro de sandbox. Sin él, el primero de los tuyos que sea de 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": "evacuation" }' ``` ```javascript const res = await fetch('https://sandbox.api.safeonuba.com/v1/sandbox/scenarios', { method: 'POST', headers: { Authorization: `Bearer ${await tokenSafeOnuba()}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ scenario: 'evacuation' }), }); const ejecucion = 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 {token_safeonuba()}"}, json={"scenario": "evacuation"}, timeout=10, ) res.raise_for_status() ejecucion = 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 ejecucion = await res.Content.ReadFromJsonAsync(); // run_id, scenario, site_id, resource, ends_at ``` La respuesta (`202`) dice qué ha abierto (`resource`: una alerta o una evacuación, con su `id`) y cuándo se cierra solo (`ends_at`). Consúltalo con su `GET` o espera al webhook. ## Escenarios de alerta | Escenario | Qué pasa | | --- | --- | | `sos` | Una persona de prueba pulsa el SOS. | | `fall` | Una persona de prueba se cae. | | `heat_stress` | Una persona de prueba sufre estrés térmico. | Sale la alerta (`alert.created`). Puedes reconocerla y cerrarla desde tu sala con `PUT /v1/alerts/{id}`; si nadie la cierra antes, **se cierra sola a los 10 minutos** (`alert.resolved`). ## Evacuación completa `evacuation` es una evacuación general de **3 minutos** que recorre el ciclo entero, pensada para probar el banner de tu sala de principio a fin: | Momento | Qué pasa | Qué te llega | | --- | --- | --- | | 0 s | Empieza la evacuación. | `muster.started` | | 15 s | Llegan cuatro personas al punto A. | `muster.updated` | | 35 s | Alguien llega al punto B y la sala lo descarta. | `muster.targets_changed` | | 55 s | Una persona pide ayuda. | `alert.created` de tipo `evacuation_help` | | 95 s | Quien estaba en B se va al C. | `muster.updated` | | 3 min | Todo despejado. | `muster.ended` | Entre medias llega un `muster.updated` cada vez que cambia el recuento. Con esto puedes comprobar lo que más cuesta probar en una planta real: que tu sala pinta bien un punto descartado, que la persona que pide ayuda sube arriba en «quién falta» y que el banner se cierra al terminar. ## Límites del sandbox - Como mucho **5 escenarios abiertos a la vez** por cliente. - **Una evacuación por centro** a la vez. - Pasarse de cualquiera de los dos da `409`. - En producción, `POST /v1/sandbox/scenarios` responde `404`: solo existe en el sandbox. - Un centro que no es de sandbox, o que tiene un reloj de verdad, responde `422` y no corre nada. ## Distinguir lo simulado Todo lo que sale de un escenario lleva `simulated: true`: las alertas, y lo que se genera a partir de ellas. Los eventos de `POST /v1/webhooks/{id}/test` llevan además `organization_id: null`. Si tu sala comparte base de datos o pantallas con producción, filtra por `simulated` para que una prueba no se confunda nunca con una alerta real. --- # Límites de uso > Peticiones y tiempo de consulta por minuto, qué pasa al superarlos, las cabeceras que lo cuentan y por qué conviene usar webhooks en vez de consultar en bucle. Página: https://safeonuba.com/desarrolladores/limites · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. Cada cliente tiene dos límites por minuto. Los dos existen para que una integración que consulta de más no quite capacidad a las alertas de nadie, la tuya incluida. ## Los dos límites | Límite | De serie | | --- | --- | | Peticiones por minuto | 600 | | Tiempo de consulta por minuto | 30 segundos | El segundo es menos habitual. Cada petición gasta el tiempo que tarda en resolverse, así que **una lista larga cuenta más que una alerta suelta**: pedir 500 alertas de un mes consume más de ese presupuesto que pedir una por su `id`. Si tu integración pide listas grandes a menudo, pagina con `limit` más bajos o, mejor, deja de consultar y usa webhooks. Los dos se pueden ampliar por contrato. El endpoint del token (`POST /v1/oauth/token`) tiene además su propio límite, por IP. Pide un token por hora y reutilízalo (ver [Renovarlo](https://safeonuba.com/desarrolladores/autenticacion#renovarlo)). ## Cabeceras Las respuestas llevan cuánto margen te queda: | Cabecera | Qué dice | | --- | --- | | `X-RateLimit-Limit` | Tu límite. | | `X-RateLimit-Remaining` | Lo que te queda en la ventana actual. | ## Al superarlos La API responde `429` en `application/problem+json`, con la cabecera `Retry-After`: los segundos que tienes que esperar antes de volver a intentarlo. ```javascript async function pedir(url, opciones = {}, intentos = 3) { const res = await fetch(url, opciones); if (res.status === 429 && intentos > 0) { const espera = Number(res.headers.get('Retry-After') ?? 1); await new Promise((r) => setTimeout(r, espera * 1000)); return pedir(url, opciones, intentos - 1); } return res; } ``` ```python import time import requests def pedir(metodo: str, url: str, intentos: int = 3, **kwargs) -> requests.Response: res = requests.request(metodo, url, timeout=10, **kwargs) if res.status_code == 429 and intentos > 0: time.sleep(float(res.headers.get("Retry-After", "1"))) return pedir(metodo, url, intentos - 1, **kwargs) return res ``` ```csharp async Task Pedir(Func> peticion, int intentos = 3) { var res = await peticion(); if ((int)res.StatusCode == 429 && intentos > 0) { var espera = res.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1); await Task.Delay(espera); return await Pedir(peticion, intentos - 1); } return res; } ``` Respeta siempre `Retry-After`. Reintentar antes solo alarga la espera. ## Webhooks en vez de consultar en bucle La causa más común de un `429` es preguntar cada pocos segundos «¿hay alertas nuevas?». Es lo que los webhooks hacen mejor: te llega cada alerta en uno o dos segundos, sin gastar ni una petición. - Para enterarte de lo que pasa: [webhooks](https://safeonuba.com/desarrolladores/webhooks). - Para ponerte al día tras un corte: [`GET /v1/events`](https://safeonuba.com/desarrolladores/eventos#recuperar-lo-perdido). - Para cargar el estado al arrancar o buscar algo concreto: las listas, paginadas. --- # Errores > Los errores llegan en application/problem+json (RFC 9457) con el código HTTP real y un code estable para programar contra él. Página: https://safeonuba.com/desarrolladores/errores · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. Los errores llegan con el **código HTTP real** y un cuerpo en `application/problem+json`, el formato de la [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457). La excepción es el endpoint del token, que sigue el formato de OAuth2 (ver [Errores del token](https://safeonuba.com/desarrolladores/autenticacion#errores-del-token)). ## El cuerpo | Campo | Qué es | | --- | --- | | `type` | URI que identifica el tipo de problema. | | `title` | Resumen legible. | | `status` | El código HTTP, repetido. | | `detail` | Explicación de este caso concreto. | | `instance` | La ruta que ha fallado. | | `code` | **Código estable del error, para programar contra él.** El texto de `title` y `detail` puede cambiar; `code` no. | ```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" } ``` Programa contra `status` y `code`, nunca contra el texto. ## Códigos | Código | Cuándo | Qué hacer | | --- | --- | --- | | `401` | Falta el token, ha caducado o se ha revocado. | Pide un token nuevo y reintenta una vez. | | `403` | Al token le falta el scope que pide la ruta, o el centro no es de tu cliente. | Pide el token con ese scope; si tu cliente no lo tiene, pídelo. No reintentes. | | `404` | No existe, o tu identidad no lo ve (otro centro, otra contrata). | No reintentes. | | `409` | La transición no es posible: por ejemplo, reconocer o cerrar una alerta ya cerrada. En el sandbox, también demasiados escenarios abiertos. | Vuelve a leer el recurso: probablemente ya está en el estado que querías. | | `422` | La petición no cumple el contrato: un campo que falta, un valor fuera de rango, una URL de webhook que no es `https`. | Corrige la petición. `detail` dice qué falla. | | `429` | Has superado un límite de uso. | Espera lo que diga `Retry-After` (ver [Límites de uso](https://safeonuba.com/desarrolladores/limites)). | > **Un 404 no siempre es «no existe»** > > La API ve lo mismo que una persona del panel con esos permisos. Una alerta de otro centro, o de una contrata que tu identidad no cubre, responde `404` igual que una que no existe. No filtra que exista. ## Reintentar o no - `401`: sí, una vez, con un token nuevo. - `429`: sí, después de `Retry-After`. - Errores de red y `5xx`: sí, con espera creciente. Las lecturas se pueden repetir sin problema. Antes de repetir un `PUT /v1/alerts/{id}`, vuelve a leer la alerta: puede que el primero sí llegara. - `403`, `404`, `409` y `422`: no. Repetir la misma petición dará el mismo resultado. --- # Paginación > Las listas se recorren con limit y un cursor opaco: la respuesta trae next_cursor y se pasa como cursor. Página: https://safeonuba.com/desarrolladores/paginacion · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. Las listas de la API (`/v1/alerts`, `/v1/musters`, `/v1/workers`, `/v1/devices`, `/v1/events`) se recorren con un **cursor**. ## Cómo funciona | Parámetro | Qué es | | --- | --- | | `limit` | Elementos por página, de 1 a 500. Por defecto, 100. | | `cursor` | El `next_cursor` de la página anterior. En la primera página, no se manda. | Cada respuesta trae la página en `data` y un `next_cursor`: ```json { "data": [ … ], "next_cursor": "eyJ0IjoiMjAyNi0wOS0yNFQxMTowNDoxMloifQ" } ``` Para la página siguiente, repite la misma petición con los mismos filtros y `cursor=`. Cuando `next_cursor` es `null`, no hay más. > **El cursor es opaco** > > No lo interpretes ni lo construyas: pásalo tal cual. ## Recorrer una lista entera ```javascript async function* todas(ruta, filtros = {}) { let cursor = null; do { const url = new URL(`https://sandbox.api.safeonuba.com${ruta}`); for (const [k, v] of Object.entries(filtros)) 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 tokenSafeOnuba()}` } }); if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`); const pagina = await res.json(); yield* pagina.data; cursor = pagina.next_cursor; } while (cursor); } for await (const alerta of todas('/v1/alerts', { status: 'unacknowledged' })) { console.log(alerta.id, alerta.title); } ``` ```python def todas(ruta: str, **filtros): cursor = None while True: params = {**filtros, "limit": 100} if cursor: params["cursor"] = cursor res = requests.get( f"https://sandbox.api.safeonuba.com{ruta}", headers={"Authorization": f"Bearer {token_safeonuba()}"}, params=params, timeout=30, ) res.raise_for_status() pagina = res.json() yield from pagina["data"] cursor = pagina["next_cursor"] if not cursor: return for alerta in todas("/v1/alerts", status="unacknowledged"): print(alerta["id"], alerta["title"]) ``` ```csharp async IAsyncEnumerable Todas(string ruta, string filtros = "") { string? cursor = null; do { var url = $"https://sandbox.api.safeonuba.com{ruta}?limit=100{filtros}" + (cursor is null ? "" : $"&cursor={Uri.EscapeDataString(cursor)}"); var res = await http.GetAsync(url); res.EnsureSuccessStatusCode(); var pagina = await res.Content.ReadFromJsonAsync(); foreach (var elemento in pagina.GetProperty("data").EnumerateArray()) yield return elemento; var siguiente = pagina.GetProperty("next_cursor"); cursor = siguiente.ValueKind == JsonValueKind.Null ? null : siguiente.GetString(); } while (cursor is not null); } await foreach (var alerta in Todas("/v1/alerts", "&status=unacknowledged")) Console.WriteLine(alerta.GetProperty("title").GetString()); ``` ## Orden de cada lista - `/v1/alerts`: de la más reciente a la más antigua. - `/v1/events`: del más antiguo al más reciente, en orden de publicación. Así se reproduce en el mismo orden en que se envió. ## Lo que cuesta una página Una página de 500 elementos cuenta más para tu límite de tiempo de consulta que una de 50 (ver [Límites de uso](https://safeonuba.com/desarrolladores/limites)). Pide lo que vas a usar. Las listas cortas que no se paginan (`/v1/sites`, `/v1/sites/{id}/zones`, `/v1/musters/{id}/missing`, `/v1/positions/latest`, `/v1/webhooks`) devuelven todo en `data`, sin `next_cursor`. --- # Versiones > Qué significa v1 en la ruta y api_version en cada evento, y en qué estado está hoy el contrato. Página: https://safeonuba.com/desarrolladores/versiones · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. La API tiene dos marcas de versión, y cada una dice una cosa distinta. ## v1 en la ruta Todas las rutas van bajo `/v1`. Es la versión mayor del contrato: las rutas, los objetos y su significado. ## api_version en cada evento Cada evento lleva `api_version: "2026-09-24"`, y el `User-Agent` de los webhooks lo repite (`SafeOnuba-Webhooks/2026-09-24`). Es la fecha de la versión del formato con el que se ha generado ese evento. Guárdala junto a cada evento que recibas. Si algún día conviven formatos, es lo que te dirá con cuál se escribió cada uno. ## Estado actual: vista previa > **v1 · vista previa** > > El contrato v1 está pendiente del visto bueno del primer cliente y **puede cambiar**. Cualquier cambio se anotará en el [changelog](https://safeonuba.com/desarrolladores/changelog), y el contrato publicado es siempre el que manda. Mientras dure la vista previa: - Genera tu cliente, si lo haces, desde el contrato ([OpenAPI 3.1](https://safeonuba.com/desarrolladores/openapi.json)) y vuelve a generarlo cuando cambie. - Ignora los campos que no conozcas en lugar de fallar. Es la forma más sencilla de que un campo nuevo no rompa tu integración. - Programa contra el `code` de los errores y contra los valores de los enums, no contra textos. ## El contrato El contrato OpenAPI 3.1 es la fuente de verdad: la [referencia](https://safeonuba.com/desarrolladores/referencia) de esta documentación se genera desde él en cada despliegue. Lo tienes para descargar en [/desarrolladores/openapi.json](https://safeonuba.com/desarrolladores/openapi.json), y la API lo sirve en `GET /v1/openapi.json`, sin token. --- # Privacidad y seguridad > La API ve lo mismo que una persona del panel con esos permisos. Posiciones solo cuando la planta lo permite, nunca constantes vitales, y cada petición registrada. Página: https://safeonuba.com/desarrolladores/privacidad-y-seguridad · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. Esta página es para el equipo técnico que integra, y también para el comité de empresa, la representación de los trabajadores o el delegado de protección de datos que tiene que saber qué sale de la planta y a dónde va. Todo lo que aquí se dice se cumple por diseño en la propia API, no depende de que el integrador se porte bien. ## La API ve lo mismo que el panel, ni más ni menos Una integración no es una puerta trasera. La API ve exactamente lo que vería una persona del panel de SafeOnuba con esos mismos permisos: - el mismo nivel de privacidad de la planta; - las mismas zonas de privacidad; - el mismo alcance por contrata: lo que esa identidad no ve, para la API no existe (responde `404`). Y los scopes limitan todavía más: un token sin `positions:read` no ve ninguna posición, aunque la planta la dejara ver. ## Posiciones: solo cuando la planta lo permite Cada planta elige su nivel de privacidad. El de serie es **«Solo en emergencia»**: la posición de una persona solo se ve cuando tiene una alerta abierta a partir de cierta gravedad, durante una evacuación o con una solicitud aprobada en el panel. Cuando la posición existe pero la privacidad no deja verla, la alerta llega igual, sin la posición y con `location_withheld: true`. Tampoco se ve en el panel. Cada posición que la API entrega con una alerta detrás queda registrada en esa alerta, en «Quién ha visto su posición». Detalle técnico: [Posiciones](https://safeonuba.com/desarrolladores/conceptos/posiciones). ## Zonas de privacidad En las zonas de privacidad —vestuarios, comedores— **no se registra la posición**. La API da el nombre y el tipo de esas zonas, pero no su polígono. Y quien estaba en una de ellas no cuenta en `nearby`, la lista de quién había cerca de un incidente. ## Nunca hay constantes vitales La API **no entrega constantes vitales**, ni de una persona ni agregadas. Ningún campo lleva pulso, temperatura corporal ni nada que se derive de ellos. Una alerta de estrés térmico dice lo que se le ha indicado al trabajador en su reloj (parar y descansar), el índice de calor de la planta y los minutos al sol. Nada que salga de su cuerpo. ## Nombres opcionales Los nombres de los trabajadores son opcionales por contrato. Sin ellos, cada persona se identifica solo por el código que ya usa su empresa (`external_id`), y los títulos de las alertas se componen sin nombre. ## Quién tiene acceso, y por qué, a la vista - **Cada cliente de la API lo damos de alta nosotros**, con un motivo que queda registrado. - **Cada petición queda registrada.** - **La planta lo ve en su panel**, en Ajustes → Integraciones: quién tiene acceso, a qué y con qué motivo. - **Las acciones desde la sala del cliente llevan nombre.** Reconocer o cerrar una alerta por la API exige el operador que lo hace, y la cronología de la alerta lo muestra como «Cliente · Operador (vía integración)». ## Qué no hace la API - **No abre evacuaciones.** El sistema puede proponerlas; las decide siempre una persona en el panel. - **No se puede usar desde un navegador.** No tiene CORS: las llamadas salen del servidor del cliente, que es donde viven las credenciales. ## Seguridad del transporte y de las credenciales - **Todo el tráfico va cifrado con TLS**, también los webhooks: un destino solo puede ser una URL `https` en una dirección pública, y no se siguen redirecciones. - **Los tokens duran una hora** y se revocan al instante si se da de baja al cliente. - **Los secretos se enseñan una sola vez**, tanto el `client_secret` como el secreto de firma de cada webhook, y los dos se pueden rotar sin cortes. - **Cada webhook va firmado** con HMAC-SHA256 y un timestamp, y el receptor rechaza lo que no cuadre o tenga más de 5 minutos (ver [Verificar la firma](https://safeonuba.com/desarrolladores/webhooks/verificar-firma)). ## Preguntas que suele hacer un comité **¿Puede la empresa seguir a un trabajador por la API?** Solo si la planta ha elegido un nivel de privacidad que lo permita en el panel, y con los mismos límites. Con «Solo en emergencia», que es el de serie, la API no ve la posición de nadie que no tenga una emergencia abierta. **¿Puede la integración saber cómo está de salud alguien?** No. No hay constantes vitales en la API, ni sueltas ni agregadas. **¿Cómo sabemos quién está conectado?** En el panel, Ajustes → Integraciones: quién, a qué y con qué motivo. Y cada petición queda registrada. **¿Y si la integración reconoce o cierra una alerta?** Queda en la cronología de la alerta con el nombre del operador que lo hizo y la marca «vía integración». --- # Changelog > Los cambios del contrato de la API, del más reciente al más antiguo. Página: https://safeonuba.com/desarrolladores/changelog · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. Los cambios del contrato de la API, del más reciente al más antiguo. Mientras v1 esté en vista previa, un cambio puede no ser compatible con el anterior: aquí se dirá cuándo lo es y cuándo no. ## 2026-09-24 · v1 en vista previa Primera versión publicada del contrato (`api_version: "2026-09-24"`), pendiente del visto bueno del primer cliente. - Autenticación OAuth2 `client_credentials` y seis scopes. - Centros, zonas y puntos de reunión. - Alertas: consulta, y reconocer o cerrar desde la sala del cliente. - Evacuaciones: estado, recuento, puntos descartados y quién falta. - Trabajadores, relojes y últimas posiciones visibles. - Webhooks firmados con HMAC-SHA256, con reintentos durante 24 horas y rotación del secreto sin cortes. - Reproducción de eventos de los últimos 30 días con `GET /v1/events`. - Sandbox con escenarios de SOS, caída, estrés térmico y evacuación completa. --- # Preguntas frecuentes > Cómo pedir acceso, a quién escribir para soporte y las dudas que suelen salir al integrar. Página: https://safeonuba.com/desarrolladores/preguntas-frecuentes · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. ## Acceso y soporte ### ¿Cómo pido acceso a la API? Escríbenos a [info@safeonuba.com](mailto:info@safeonuba.com?subject=Acceso%20a%20la%20API%20de%20SafeOnuba) con tu empresa, la planta y qué quieres integrar. El acceso lo damos de alta nosotros, cliente a cliente, con un motivo que queda registrado y que la planta ve en su panel. ### ¿Puedo probar antes de tener acceso a producción? Sí, y es lo recomendado: se integra en el [sandbox](https://safeonuba.com/desarrolladores/sandbox), con relojes simulados. Producción se activa al dar de alta al cliente. ### ¿Está abierta ya la API de producción? Todavía no está abierta al público. `https://api.safeonuba.com` se activa al dar de alta a cada cliente. ### ¿A quién escribo si algo no funciona? A [info@safeonuba.com](mailto:info@safeonuba.com?subject=Soporte%20de%20la%20API%20de%20SafeOnuba). Si es un error de la API, incluye el `code` y el `instance` del cuerpo del error, la hora (UTC) y, si es un webhook, el `X-SafeOnuba-Event-Id`. **Nunca mandes el `client_secret`, un token ni el secreto de un webhook.** ## Integración ### ¿Puedo llamar a la API desde el navegador o desde una página de la intranet? No directamente. La API no tiene CORS, y además el `client_secret` no debe estar nunca en un navegador. Pon un servicio tuyo en medio: tu servidor llama a la API y tu página habla con tu servidor. ### ¿Puedo abrir una evacuación desde mi sala? No en v1. El sistema propone y una persona decide en el panel de SafeOnuba. Tu sala recibe la evacuación en cuanto empieza. ### ¿Cuánto tarda en llegar una alerta? Desde que salta un SOS hasta que llega a tu receptor pasan normalmente 1 o 2 segundos. Es una medición típica, no un compromiso de servicio. ### Me ha llegado el mismo evento dos veces. Es normal: la entrega es «al menos una vez». Deduplica por el `id` del evento. Ver [Duplicados](https://safeonuba.com/desarrolladores/webhooks#duplicados). ### Los eventos me llegan desordenados. También es normal: lo urgente sale primero y los reintentos llegan cuando llegan. Quédate con el más reciente. Ver [Orden](https://safeonuba.com/desarrolladores/webhooks#orden). ### Mi receptor ha estado caído. ¿He perdido eventos? No, si lo recuperas antes de 30 días: `GET /v1/events` devuelve los mismos eventos, byte a byte. Ver [Recuperar lo perdido](https://safeonuba.com/desarrolladores/eventos#recuperar-lo-perdido). ### La firma no me cuadra. Casi siempre es que se está calculando sobre el JSON ya parseado y vuelto a serializar, y no sobre el cuerpo en bruto. Ver [Verificar la firma](https://safeonuba.com/desarrolladores/webhooks/verificar-firma#errores-frecuentes). ### Una alerta me llega sin posición. Es la privacidad de la planta: la alerta está por debajo de la gravedad que deja ver la posición, y trae `location_withheld: true`. Ver [Posiciones](https://safeonuba.com/desarrolladores/conceptos/posiciones). ### ¿Por qué no veo los nombres de los trabajadores? Los nombres son opcionales por contrato. Sin ellos, cada persona se identifica por su `external_id`. Ver [Trabajadores y relojes](https://safeonuba.com/desarrolladores/conceptos/trabajadores-y-relojes#sin-nombres). ### ¿Puedo usar un asistente de IA para integrar? Sí. Cada página tiene el botón **Copiar**, arriba a la derecha, que la copia en Markdown, y su versión en texto añadiendo `.md` a la dirección. Para dárselo todo de una vez, [/llms-full.txt](https://safeonuba.com/llms-full.txt). Ver [Usar esta documentación con IA](https://safeonuba.com/desarrolladores#usar-esta-documentacion-con-ia). ### ¿Puede SafeOnuba recibir alarmas de mis sistemas? Existe una entrada para alarmas de terceros, pero se configura bajo petición y no forma parte de esta API pública. Escríbenos si lo necesitas. --- # Referencia de la API > Todas las operaciones, parámetros, respuestas y esquemas de la API v1, generados desde el contrato OpenAPI 3.1. Página: https://safeonuba.com/desarrolladores/referencia · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. ## Entornos | Entorno | Dirección | | --- | --- | | Producción | `https://api.safeonuba.com` | | Pruebas, con relojes simulados | `https://sandbox.api.safeonuba.com` | Contrato OpenAPI 3.1: https://safeonuba.com/desarrolladores/openapi.json ## [Autenticación](https://safeonuba.com/desarrolladores/referencia/autenticacion.md) OAuth2 `client_credentials`: un token opaco de una hora. - `POST /v1/oauth/token`: Pedir un token (client_credentials) ## [Centros](https://safeonuba.com/desarrolladores/referencia/centros.md) Los centros del cliente y sus zonas. - `GET /v1/sites`: Centros del cliente - `GET /v1/sites/{id}/zones`: Zonas y puntos de reunión de un centro ## [Alertas](https://safeonuba.com/desarrolladores/referencia/alertas.md) Lo que ha pasado, y reconocerlo o cerrarlo desde la sala del cliente. - `GET /v1/alerts`: Listar alertas - `GET /v1/alerts/{id}`: Detalle de una alerta - `PUT /v1/alerts/{id}`: Reconocer o cerrar una alerta ## [Evacuaciones](https://safeonuba.com/desarrolladores/referencia/evacuaciones.md) Estado, recuento por estado y por punto, descartes y quién falta. - `GET /v1/musters`: Listar evacuaciones - `GET /v1/musters/{id}`: Estado y recuento de una evacuación - `GET /v1/musters/{id}/missing`: Quién falta ## [Trabajadores](https://safeonuba.com/desarrolladores/referencia/trabajadores.md) Trabajadores y relojes. - `GET /v1/workers`: Listar trabajadores - `GET /v1/workers/{id}`: Detalle de un trabajador - `GET /v1/devices`: Listar relojes ## [Posiciones](https://safeonuba.com/desarrolladores/referencia/posiciones.md) Solo las que el velo deja ver. - `GET /v1/positions/latest`: Últimas posiciones visibles ## [Eventos](https://safeonuba.com/desarrolladores/referencia/eventos.md) Reproducir los eventos de los últimos 30 días. - `GET /v1/events`: Reproducir eventos ## [Webhooks](https://safeonuba.com/desarrolladores/referencia/webhooks.md) Destinos y el formato de lo que se les envía. - `GET /v1/webhooks`: Listar destinos - `POST /v1/webhooks`: Crear un destino - `DELETE /v1/webhooks/{id}`: Borrar un destino - `POST /v1/webhooks/{id}/rotate-secret`: Rotar el secreto de firma - `POST /v1/webhooks/{id}/test`: Enviar un evento de prueba ## [Sandbox](https://safeonuba.com/desarrolladores/referencia/sandbox.md) Escenarios de prueba con relojes simulados, para integrar sin relojes. Solo en `sandbox.api.safeonuba.com`. - `POST /v1/sandbox/scenarios`: Lanzar un escenario de prueba ## [Esquemas](https://safeonuba.com/desarrolladores/referencia/esquemas.md) --- # Autenticación · Referencia > OAuth2 `client_credentials`: un token opaco de una hora. Página: https://safeonuba.com/desarrolladores/referencia/autenticacion · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. ## Pedir un token (client_credentials) `POST /v1/oauth/token` · sin token · [ver en la web](https://safeonuba.com/desarrolladores/referencia/autenticacion#createToken) OAuth2 `client_credentials`. El token es opaco, dura una hora y se revoca al instante al dar de baja al cliente. ### Cuerpo (`application/x-www-form-urlencoded`, esquema `TokenRequest`) - `grant_type` (string, obligatorio) Valores: `client_credentials`. - `client_id` (string, obligatorio) - `client_secret` (string, obligatorio) - `scope` (string): Separados por espacios; subconjunto de los del cliente. Sin él, todos los del cliente. ### Respuestas - `200`: Token emitido. → `TokenResponse` (`application/json`) - `400`: Petición mal formada o scope no permitido. → `OAuthError` (`application/json`) - `401`: Cliente o secreto incorrectos. → `OAuthError` (`application/json`) ### Ejemplo de petición (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" ``` ### Respuesta de ejemplo (`200`) ```json { "access_token": "at_9c1e…", "token_type": "Bearer", "expires_in": 3600, "scope": "alerts:read alerts:write musters:read" } ``` --- # Centros · Referencia > Los centros del cliente y sus zonas. Página: https://safeonuba.com/desarrolladores/referencia/centros · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. ## Centros del cliente `GET /v1/sites` · cualquier token · [ver en la web](https://safeonuba.com/desarrolladores/referencia/centros#listSites) ### Respuestas - `200`: Centros a los que accede el cliente. → `SiteList` (`application/json`) - `401`: Falta el token, ha caducado o se ha revocado. → `Problem` (`application/problem+json`) - `403`: Al token le falta el scope que pide la ruta, o el centro no es del cliente. → `Problem` (`application/problem+json`) - `429`: Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver. → `Problem` (`application/problem+json`) ### Ejemplo de petición (sandbox) ```bash curl "https://sandbox.api.safeonuba.com/v1/sites" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Respuesta de ejemplo (`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" } ] } ``` ## Zonas y puntos de reunión de un centro `GET /v1/sites/{id}/zones` · scope `alerts:read` · [ver en la web](https://safeonuba.com/desarrolladores/referencia/centros#listSiteZones) GeoJSON en WGS84. Las zonas de privacidad, con nombre y tipo pero sin geometría. ### Parámetros de ruta - `id` (string (uuid), obligatorio): Id del centro. ### Respuestas - `200`: Zonas en vigor. → `ZoneList` (`application/json`) - `401`: Falta el token, ha caducado o se ha revocado. → `Problem` (`application/problem+json`) - `403`: Al token le falta el scope que pide la ruta, o el centro no es del cliente. → `Problem` (`application/problem+json`) - `404`: El centro no existe o no es del cliente. → `Problem` (`application/problem+json`) - `429`: Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver. → `Problem` (`application/problem+json`) ### Ejemplo de petición (sandbox) ```bash curl "https://sandbox.api.safeonuba.com/v1/sites/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90/zones" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Respuesta de ejemplo (`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 } } ] } ``` --- # Alertas · Referencia > Lo que ha pasado, y reconocerlo o cerrarlo desde la sala del cliente. Página: https://safeonuba.com/desarrolladores/referencia/alertas · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. ## Listar alertas `GET /v1/alerts` · scope `alerts:read` · [ver en la web](https://safeonuba.com/desarrolladores/referencia/alertas#listAlerts) ### Parámetros de consulta - `site_id` (string (uuid)) - `type` (AlertType): Qué ha pasado. `earthquake` y `tsunami_risk` son de la planta entera: no llevan `worker` ni `device`. Un vehículo cerca **no** es una alerta: sale dentro de la alerta de un incidente (`nearby`, `vehicle_warnings`). Valores: `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`: un operador se ha hecho cargo. No es lo mismo que `worker_acknowledged_at`, que es que el trabajador ha visto el aviso en su reloj. Valores: `unacknowledged`, `acknowledged`, `resolved`. - `severity` (Severity): Gravedad mínima. Valores: `low`, `medium`, `high`, `critical`. - `since` (string (date-time)) - `until` (string (date-time)) - `limit` (integer): Elementos por página (máximo 500). (de 1 a 500, por defecto 100) - `cursor` (string): El `next_cursor` de la página anterior. ### Respuestas - `200`: Alertas, de la más reciente a la más antigua. → `AlertPage` (`application/json`) - `401`: Falta el token, ha caducado o se ha revocado. → `Problem` (`application/problem+json`) - `403`: Al token le falta el scope que pide la ruta, o el centro no es del cliente. → `Problem` (`application/problem+json`) - `429`: Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver. → `Problem` (`application/problem+json`) ### Ejemplo de petición (sandbox) ```bash curl "https://sandbox.api.safeonuba.com/v1/alerts" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Respuesta de ejemplo (`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" } ``` ## Detalle de una alerta `GET /v1/alerts/{id}` · scope `alerts:read` · [ver en la web](https://safeonuba.com/desarrolladores/referencia/alertas#getAlert) ### Parámetros de ruta - `id` (string (uuid), obligatorio): Id de la alerta. ### Respuestas - `200`: La alerta. → `Alert` (`application/json`) - `401`: Falta el token, ha caducado o se ha revocado. → `Problem` (`application/problem+json`) - `403`: Al token le falta el scope que pide la ruta, o el centro no es del cliente. → `Problem` (`application/problem+json`) - `404`: No existe, o esta identidad no la ve (otro centro, otra contrata). → `Problem` (`application/problem+json`) - `429`: Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver. → `Problem` (`application/problem+json`) ### Ejemplo de petición (sandbox) ```bash curl "https://sandbox.api.safeonuba.com/v1/alerts/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Respuesta de ejemplo (`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" } ``` ## Reconocer o cerrar una alerta `PUT /v1/alerts/{id}` · scope `alerts:write` · [ver en la web](https://safeonuba.com/desarrolladores/referencia/alertas#updateAlert) La cronología de la alerta escribe «Cliente · Operador (vía integración)». Reconocer no es cerrar: una alerta reconocida sigue marcando a la persona en el mapa hasta que se cierra. ### Parámetros de ruta - `id` (string (uuid), obligatorio): Id de la alerta. ### Cuerpo (`application/json`, esquema `AlertUpdate`) - `status` (string, obligatorio) Valores: `acknowledged`, `resolved`. - `resolution_reason` (string): Al cerrar: por qué. Texto libre; va a la cronología de la alerta junto a la nota. (hasta 200 caracteres) - `note` (string) (hasta 500 caracteres) - `actor` (Actor, obligatorio) - `name` (string, obligatorio) (hasta 120 caracteres) - `external_id` (string) (hasta 64 caracteres) ### Respuestas - `200`: La alerta, ya actualizada. → `Alert` (`application/json`) - `401`: Falta el token, ha caducado o se ha revocado. → `Problem` (`application/problem+json`) - `403`: Al token le falta el scope que pide la ruta, o el centro no es del cliente. → `Problem` (`application/problem+json`) - `404`: No existe, o esta identidad no la ve. → `Problem` (`application/problem+json`) - `409`: La transición no es posible (por ejemplo, reconocer una alerta ya cerrada). → `Problem` (`application/problem+json`) - `422`: El cuerpo no es válido. → `Problem` (`application/problem+json`) - `429`: Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver. → `Problem` (`application/problem+json`) ### Ejemplo de petición (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" } }' ``` ### Respuesta de ejemplo (`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" } ``` --- # Evacuaciones · Referencia > Estado, recuento por estado y por punto, descartes y quién falta. Página: https://safeonuba.com/desarrolladores/referencia/evacuaciones · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. ## Listar evacuaciones `GET /v1/musters` · scope `musters:read` · [ver en la web](https://safeonuba.com/desarrolladores/referencia/evacuaciones#listMusters) ### Parámetros de consulta - `site_id` (string (uuid)) - `status` (string) Valores: `active`, `completed`, `cancelled`. - `since` (string (date-time)) - `limit` (integer): Elementos por página (máximo 500). (de 1 a 500, por defecto 100) - `cursor` (string): El `next_cursor` de la página anterior. ### Respuestas - `200`: Evacuaciones activas e históricas. → `MusterPage` (`application/json`) - `401`: Falta el token, ha caducado o se ha revocado. → `Problem` (`application/problem+json`) - `403`: Al token le falta el scope que pide la ruta, o el centro no es del cliente. → `Problem` (`application/problem+json`) - `429`: Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver. → `Problem` (`application/problem+json`) ### Ejemplo de petición (sandbox) ```bash curl "https://sandbox.api.safeonuba.com/v1/musters" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Respuesta de ejemplo (`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" } ``` ## Estado y recuento de una evacuación `GET /v1/musters/{id}` · scope `musters:read` · [ver en la web](https://safeonuba.com/desarrolladores/referencia/evacuaciones#getMuster) El recuento lo cuenta el servidor con la misma regla que el panel y el cierre: posición precisa dentro de un punto válido, o llegada declarada a uno. ### Parámetros de ruta - `id` (string (uuid), obligatorio): Id de la evacuación. ### Respuestas - `200`: La evacuación. → `Muster` (`application/json`) - `401`: Falta el token, ha caducado o se ha revocado. → `Problem` (`application/problem+json`) - `403`: Al token le falta el scope que pide la ruta, o el centro no es del cliente. → `Problem` (`application/problem+json`) - `404`: No existe o no es de un centro del cliente. → `Problem` (`application/problem+json`) - `429`: Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver. → `Problem` (`application/problem+json`) ### Ejemplo de petición (sandbox) ```bash curl "https://sandbox.api.safeonuba.com/v1/musters/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Respuesta de ejemplo (`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 } ] } ``` ## Quién falta `GET /v1/musters/{id}/missing` · scope `musters:read` · [ver en la web](https://safeonuba.com/desarrolladores/referencia/evacuaciones#listMissingWorkers) Primero quien pide ayuda o necesita rescate, después quien está en un punto que no sirve, sin señal, sin el reloj puesto y saliendo. La última posición solo con el scope `positions:read`, y si el velo la deja ver. ### Parámetros de ruta - `id` (string (uuid), obligatorio): Id de la evacuación. ### Respuestas - `200`: Quién falta, por urgencia. → `MissingWorkerList` (`application/json`) - `401`: Falta el token, ha caducado o se ha revocado. → `Problem` (`application/problem+json`) - `403`: Al token le falta el scope que pide la ruta, o el centro no es del cliente. → `Problem` (`application/problem+json`) - `404`: No existe o no es de un centro del cliente. → `Problem` (`application/problem+json`) - `429`: Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver. → `Problem` (`application/problem+json`) ### Ejemplo de petición (sandbox) ```bash curl "https://sandbox.api.safeonuba.com/v1/musters/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90/missing" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Respuesta de ejemplo (`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 } ] } ``` --- # Trabajadores · Referencia > Trabajadores y relojes. Página: https://safeonuba.com/desarrolladores/referencia/trabajadores · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. ## Listar trabajadores `GET /v1/workers` · scope `workers:read` · [ver en la web](https://safeonuba.com/desarrolladores/referencia/trabajadores#listWorkers) ### Parámetros de consulta - `site_id` (string (uuid)) - `limit` (integer): Elementos por página (máximo 500). (de 1 a 500, por defecto 100) - `cursor` (string): El `next_cursor` de la página anterior. ### Respuestas - `200`: Trabajadores. → `WorkerPage` (`application/json`) - `401`: Falta el token, ha caducado o se ha revocado. → `Problem` (`application/problem+json`) - `403`: Al token le falta el scope que pide la ruta, o el centro no es del cliente. → `Problem` (`application/problem+json`) - `429`: Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver. → `Problem` (`application/problem+json`) ### Ejemplo de petición (sandbox) ```bash curl "https://sandbox.api.safeonuba.com/v1/workers" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Respuesta de ejemplo (`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" } ``` ## Detalle de un trabajador `GET /v1/workers/{id}` · scope `workers:read` · [ver en la web](https://safeonuba.com/desarrolladores/referencia/trabajadores#getWorker) ### Parámetros de ruta - `id` (string (uuid), obligatorio): Id del trabajador. ### Respuestas - `200`: El trabajador. → `Worker` (`application/json`) - `401`: Falta el token, ha caducado o se ha revocado. → `Problem` (`application/problem+json`) - `403`: Al token le falta el scope que pide la ruta, o el centro no es del cliente. → `Problem` (`application/problem+json`) - `404`: No existe o no lo ve esta identidad. → `Problem` (`application/problem+json`) - `429`: Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver. → `Problem` (`application/problem+json`) ### Ejemplo de petición (sandbox) ```bash curl "https://sandbox.api.safeonuba.com/v1/workers/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Respuesta de ejemplo (`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" } ``` ## Listar relojes `GET /v1/devices` · scope `workers:read` · [ver en la web](https://safeonuba.com/desarrolladores/referencia/trabajadores#listDevices) ### Parámetros de consulta - `site_id` (string (uuid)) - `limit` (integer): Elementos por página (máximo 500). (de 1 a 500, por defecto 100) - `cursor` (string): El `next_cursor` de la página anterior. ### Respuestas - `200`: Relojes: batería, si está puesto, última conexión. → `DevicePage` (`application/json`) - `401`: Falta el token, ha caducado o se ha revocado. → `Problem` (`application/problem+json`) - `403`: Al token le falta el scope que pide la ruta, o el centro no es del cliente. → `Problem` (`application/problem+json`) - `429`: Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver. → `Problem` (`application/problem+json`) ### Ejemplo de petición (sandbox) ```bash curl "https://sandbox.api.safeonuba.com/v1/devices" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Respuesta de ejemplo (`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" } ``` --- # Posiciones · Referencia > Solo las que el velo deja ver. Página: https://safeonuba.com/desarrolladores/referencia/posiciones · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. ## Últimas posiciones visibles `GET /v1/positions/latest` · scope `positions:read` · [ver en la web](https://safeonuba.com/desarrolladores/referencia/posiciones#listLatestPositions) Solo las que el velo deja ver ahora. Cada posición servida con una alerta detrás queda registrada en la alerta («Quién ha visto su posición»). ### Parámetros de consulta - `site_id` (string (uuid)) ### Respuestas - `200`: Posiciones. → `PositionList` (`application/json`) - `401`: Falta el token, ha caducado o se ha revocado. → `Problem` (`application/problem+json`) - `403`: Al token le falta el scope que pide la ruta, o el centro no es del cliente. → `Problem` (`application/problem+json`) - `429`: Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver. → `Problem` (`application/problem+json`) ### Ejemplo de petición (sandbox) ```bash curl "https://sandbox.api.safeonuba.com/v1/positions/latest" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Respuesta de ejemplo (`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 } ] } ``` --- # Eventos · Referencia > Reproducir los eventos de los últimos 30 días. Página: https://safeonuba.com/desarrolladores/referencia/eventos · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. ## Reproducir eventos `GET /v1/events` · cualquier token · [ver en la web](https://safeonuba.com/desarrolladores/referencia/eventos#listEvents) Los mismos que llegan por webhook, guardados 30 días: para recuperar lo perdido tras una caída del receptor. ### Parámetros de consulta - `since` (string (date-time), obligatorio): Desde cuándo (como mucho 30 días atrás). - `type` (EventType): `muster.updated`: el recuento ha cambiado (se comprueba cada 5 s, solo se emite si cambia). `muster.targets_changed`: la sala ha descartado o rehabilitado un punto de reunión. Valores: `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): Elementos por página (máximo 500). (de 1 a 500, por defecto 100) - `cursor` (string): El `next_cursor` de la página anterior. ### Respuestas - `200`: Eventos, del más antiguo al más reciente. → `EventPage` (`application/json`) - `401`: Falta el token, ha caducado o se ha revocado. → `Problem` (`application/problem+json`) - `403`: Al token le falta el scope que pide la ruta, o el centro no es del cliente. → `Problem` (`application/problem+json`) - `429`: Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver. → `Problem` (`application/problem+json`) ### Ejemplo de petición (sandbox) ```bash curl "https://sandbox.api.safeonuba.com/v1/events?since=2026-09-24T11%3A04%3A12Z" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Respuesta de ejemplo (`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 · Referencia > Destinos y el formato de lo que se les envía. Página: https://safeonuba.com/desarrolladores/referencia/webhooks · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. ## Listar destinos `GET /v1/webhooks` · scope `webhooks:manage` · [ver en la web](https://safeonuba.com/desarrolladores/referencia/webhooks#listWebhooks) ### Respuestas - `200`: Destinos. → `WebhookEndpointList` (`application/json`) - `401`: Falta el token, ha caducado o se ha revocado. → `Problem` (`application/problem+json`) - `403`: Al token le falta el scope que pide la ruta, o el centro no es del cliente. → `Problem` (`application/problem+json`) - `429`: Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver. → `Problem` (`application/problem+json`) ### Ejemplo de petición (sandbox) ```bash curl "https://sandbox.api.safeonuba.com/v1/webhooks" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Respuesta de ejemplo (`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" } ] } ``` ## Crear un destino `POST /v1/webhooks` · scope `webhooks:manage` · [ver en la web](https://safeonuba.com/desarrolladores/referencia/webhooks#createWebhook) ### Cuerpo (`application/json`, esquema `WebhookEndpointCreate`) - `url` (string, obligatorio) - `event_types` (array, obligatorio) - `filters` (object) - `site_ids` (array) - `min_severity` (Severity) Valores: `low`, `medium`, `high`, `critical`. - `contractors` (array) ### Respuestas - `201`: Destino creado, con su secreto de firma (solo esta vez). → `WebhookEndpointCreated` (`application/json`) - `401`: Falta el token, ha caducado o se ha revocado. → `Problem` (`application/problem+json`) - `403`: Al token le falta el scope que pide la ruta, o el centro no es del cliente. → `Problem` (`application/problem+json`) - `422`: El cuerpo no es válido (la URL tiene que ser https). → `Problem` (`application/problem+json`) - `429`: Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver. → `Problem` (`application/problem+json`) ### Ejemplo de petición (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.*" ] }' ``` ### Respuesta de ejemplo (`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" } ``` ## Borrar un destino `DELETE /v1/webhooks/{id}` · scope `webhooks:manage` · [ver en la web](https://safeonuba.com/desarrolladores/referencia/webhooks#deleteWebhook) ### Parámetros de ruta - `id` (string (uuid), obligatorio): Id del destino. ### Respuestas - `204`: Borrado. - `401`: Falta el token, ha caducado o se ha revocado. → `Problem` (`application/problem+json`) - `403`: Al token le falta el scope que pide la ruta, o el centro no es del cliente. → `Problem` (`application/problem+json`) - `404`: No existe. → `Problem` (`application/problem+json`) - `429`: Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver. → `Problem` (`application/problem+json`) ### Ejemplo de petición (sandbox) ```bash curl -X DELETE "https://sandbox.api.safeonuba.com/v1/webhooks/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ## Rotar el secreto de firma `POST /v1/webhooks/{id}/rotate-secret` · scope `webhooks:manage` · [ver en la web](https://safeonuba.com/desarrolladores/referencia/webhooks#rotateWebhookSecret) Da un secreto nuevo. El anterior sigue firmando 24 h: durante ese tiempo cada envío lleva las dos firmas en `X-SafeOnuba-Signature`, separadas por coma, así que el receptor puede cambiar de secreto sin cortar. Una segunda rotación retira el primero: nunca hay más de dos. ### Parámetros de ruta - `id` (string (uuid), obligatorio): Id del destino. ### Respuestas - `200`: El secreto nuevo (solo esta vez) y hasta cuándo firma el anterior. → `WebhookSecretRotated` (`application/json`) - `401`: Falta el token, ha caducado o se ha revocado. → `Problem` (`application/problem+json`) - `403`: Al token le falta el scope que pide la ruta, o el centro no es del cliente. → `Problem` (`application/problem+json`) - `404`: No existe. → `Problem` (`application/problem+json`) - `429`: Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver. → `Problem` (`application/problem+json`) ### Ejemplo de petición (sandbox) ```bash curl -X POST "https://sandbox.api.safeonuba.com/v1/webhooks/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90/rotate-secret" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` ### Respuesta de ejemplo (`200`) ```json { "secret": "whsec_6f1c0d9e2b7a4c3f8e5d1a0b9c8e7f6a", "previous_valid_until": "2026-09-24T11:04:12Z" } ``` ## Enviar un evento de prueba `POST /v1/webhooks/{id}/test` · scope `webhooks:manage` · [ver en la web](https://safeonuba.com/desarrolladores/referencia/webhooks#testWebhook) Manda un `alert.created` marcado `simulated: true`, firmado como uno real. ### Parámetros de ruta - `id` (string (uuid), obligatorio): Id del destino. ### Respuestas - `202`: En cola: el resultado se ve en el destino (`failing_since`). - `401`: Falta el token, ha caducado o se ha revocado. → `Problem` (`application/problem+json`) - `403`: Al token le falta el scope que pide la ruta, o el centro no es del cliente. → `Problem` (`application/problem+json`) - `404`: No existe. → `Problem` (`application/problem+json`) - `429`: Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver. → `Problem` (`application/problem+json`) ### Ejemplo de petición (sandbox) ```bash curl -X POST "https://sandbox.api.safeonuba.com/v1/webhooks/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90/test" \ -H "Authorization: Bearer $SAFEONUBA_TOKEN" ``` # Lo que recibe tu destino Cada evento llega a tu URL como un `POST` con este cuerpo. Los dos grupos comparten formato: cambia lo que va dentro de `data`. ## Cambios en una alerta Tu destino recibe un `POST` por cada evento `alert.*`. Va firmado; no lleva token. Un `POST` por evento a cada destino suscrito, con el objeto completo. Cabeceras: `X-SafeOnuba-Event-Id`, `X-SafeOnuba-Timestamp` (segundos Unix) y `X-SafeOnuba-Signature: v1=`. Rechaza más de 5 min de desfase. Durante una rotación llegan dos firmas separadas por coma. Acuse: cualquier `2xx` en menos de 5 s. Reintentos a los 10 s, 30 s, 2 min, 10 min, 30 min, 1 h y cada hora hasta 24 h. Entrega «al menos una vez» (deduplica por `id`) y **sin orden garantizado** (usa `date_last_modified`). SOS, caídas, «no puedo evacuar» y evacuaciones salen antes que el resto. ### Cuerpo (`application/json`, esquema `Event`) - `id` (string (uuid), obligatorio): Para deduplicar: la entrega es «al menos una vez». - `type` (EventType, obligatorio) Valores: `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, obligatorio) Valores: `2026-09-24`. - `created_at` (string (date-time), obligatorio) - `organization_id` (string (uuid) | null, obligatorio): La empresa del centro. `null` en los eventos de prueba. - `site_id` (string (uuid), obligatorio) - `data` ({ alert } | { muster }, obligatorio): El objeto completo tal como lo devolvería el `GET` en ese instante. - Variante 1: - `alert` (Alert, obligatorio) - `id` (string (uuid), obligatorio) - `type` (AlertType, obligatorio) Valores: `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, obligatorio) Valores: `low`, `medium`, `high`, `critical`. - `status` (AlertStatus, obligatorio) Valores: `unacknowledged`, `acknowledged`, `resolved`. - `title` (string, obligatorio): Con `include_worker_names`, el título que lee la sala (lleva el nombre de la persona). Sin él, uno compuesto con el tipo, la zona y el `external_id`, sin nombre. - `date_created` (string (date-time), obligatorio) - `date_last_modified` (string (date-time), obligatorio): Para ordenar: los webhooks no garantizan el orden, y lo más viejo que lo que ya se tiene se descarta. - `site` (SiteRef, obligatorio) - `id` (string (uuid), obligatorio) - `name` (string, obligatorio) - `zone` (ZoneRef, obligatorio) - `id` (string (uuid), obligatorio) - `name` (string, obligatorio) - `worker` (WorkerRef, obligatorio) - `id` (string (uuid), obligatorio) - `external_id` (string | null, obligatorio): Código del trabajador en la empresa (el de su sistema de personal). `null` si no se ha dado. - `name` (string): Nombre y apellidos. **Solo** si el contrato lo incluye; si no, la propiedad no aparece. - `contractor` (string | null, obligatorio) - `device` (DeviceRef, obligatorio) - `id` (string (uuid), obligatorio) - `serial` (string, obligatorio) - `battery` (integer | null, obligatorio) (de 0 a 100) - `date_last_seen` (string (date-time) | null, obligatorio) - `location` (Location, obligatorio) - `latitude` (number, obligatorio) (de -90 a 90) - `longitude` (number, obligatorio) (de -180 a 180) - `uncertainty_m` (number | null, obligatorio): Radio de incertidumbre en metros. `null` si no se conoce: no saberla no es tenerla perfecta. (mínimo 0) - `source` (string, obligatorio): `zone`: la persona estaba en una zona de privacidad y el punto es el de la zona, no el suyo. Valores: `gps`, `network`, `zone`. - `masked` (boolean, obligatorio): El punto es el representativo de una zona de privacidad. - `position_date_utc` (string (date-time), obligatorio) - `height` (object | null, obligatorio): Altura sobre el suelo por barómetro, cuando se puede afirmar; `null` si no. - `location_withheld` (boolean, obligatorio): La posición existe pero no se entrega: en «Solo en emergencia», una alerta por debajo de la gravedad que destapa no la enseña, tampoco en el panel. - `details` (AlertDetails, obligatorio) - Variante `SosDetails`: - `trigger` (string, obligatorio): Cómo se pidió: en la pantalla del reloj, con el botón físico, tras una caída sin respuesta o desde la sala. Valores: `watch_button`, `watch_hardware_button`, `after_fall`, `control_room`. - Variante `FallDetails`: - `escalated_to_sos` (boolean, obligatorio): No contestó al «¿Estás bien?» del reloj y pasó a SOS. - Variante `ZoneDetails`: - `zone_type` (string, obligatorio) Valores: `restricted`, `forbidden`. - `permit_reference` (string | null, obligatorio): El permiso de trabajo (ATS) que regía, si lo había. - Variante `HeatStressDetails`: - `action` (string, obligatorio): Lo que se le ha dicho en el reloj: parar, beber y buscar sombra. No hay niveles. Valores: `stop_and_rest`. - `heat_index_c` (number | null, obligatorio): Índice de calor de la planta (°C). - `sun_exposure_minutes` (integer | null, obligatorio) - Variante `EvacuationHelpDetails`: - `reason` (string | null, obligatorio): Lo que dijo en el reloj: atrapado, herido o ayudando a otra persona. Valores: `trapped`, `injured`, `helping`. - `muster_id` (string (uuid), obligatorio) - Variante `WorkerCallDetails`: - `voice_note` (boolean, obligatorio): Sin cobertura, el reloj grabó un aviso de voz (se escucha en el panel). - Variante `DeviceOfflineDetails`: - `minutes_silent` (integer, obligatorio) - Variante `LowBatteryDetails`: - `battery_pct` (integer, obligatorio) (de 0 a 100) - Variante `HazardDetails`: - `magnitude` (number | null, obligatorio) - `distance_km` (number | null, obligatorio) - `official` (boolean, obligatorio): `false`: estimación de SafeOnuba sin aviso oficial. `true`: boletín oficial confirmado por una persona. - `felt_at_site` (boolean, obligatorio) - `eta` (string (date-time) | null, obligatorio): Llegada estimada de la ola (tsunami). - `nearby` (array, obligatorio) - `kind` (string, obligatorio) Valores: `person`, `vehicle`. - `worker` (object, obligatorio) - `vehicle_type` (string | null, obligatorio) - `distance_m` (integer, obligatorio) - `reported_at` (string (date-time), obligatorio) - `vehicle_warnings` (array, obligatorio) - `at` (string (date-time), obligatorio) - `vehicle_type` (string | null, obligatorio) - `distance_m` (integer, obligatorio) - `worker_acknowledged_at` (string (date-time) | null, obligatorio) - `worker_acknowledged_at` (string (date-time) | null, obligatorio): El trabajador pulsó «Entendido» en su reloj (hora del reloj). - `acknowledged` (ActionBy, obligatorio) - `at` (string (date-time), obligatorio) - `by` (string, obligatorio): Quién: una persona del panel, o «Cliente · Operador (vía integración)». - `resolved` (object | null, obligatorio): Cerrada: quién, cuándo y con qué nota. - `at` (string (date-time), obligatorio) - `by` (string, obligatorio): Quién: una persona del panel, o «Cliente · Operador (vía integración)». - `note` (string | null, obligatorio) - `assigned_to` (string | null, obligatorio) - `simulated` (boolean, obligatorio): Generada en un simulacro o con relojes simulados. - `panel_url` (string (uri), obligatorio) - Variante 2: - `muster` (Muster, obligatorio) - `id` (string (uuid), obligatorio) - `site` (SiteRef, obligatorio) - `id` (string (uuid), obligatorio) - `name` (string, obligatorio) - `status` (string, obligatorio) Valores: `active`, `completed`, `cancelled`. - `phase` (string, obligatorio): `inspecting`: recuento completo y la sala revisa la planta; los relojes dicen «no vuelvas hasta nuevo aviso». Valores: `evacuating`, `inspecting`. - `kind` (MusterKind, obligatorio) Valores: `general`, `tsunami`, `earthquake`. - `reason` (string | null, obligatorio) - `started_at` (string (date-time), obligatorio) - `ended_at` (string (date-time) | null, obligatorio) - `deadline` (object | null, obligatorio) - `at` (string (date-time), obligatorio) - `source` (string, obligatorio): Tsunami: hora de un aviso oficial, o el techo del plan de la zona. Nunca un cálculo físico. Valores: `official`, `plan_estimate`. - `origin` (object | null, obligatorio): De dónde viene la emergencia. `null` en una evacuación sin origen (un simulacro general). - `latitude` (number, obligatorio) - `longitude` (number, obligatorio) - `radius_m` (integer, obligatorio) - `label` (string | null, obligatorio) - `zone` (ZoneRef, obligatorio) - `targets_revision` (integer, obligatorio): Sube cada vez que cambian los destinos (descarte, rehabilitación, cambio de tipo). - `totals` (object, obligatorio): Las mismas cifras que el banner del panel y el cierre: las cuenta el servidor. En una evacuación cerrada queda lo que se registró al cerrar —llegados y total—, y el resto sale `null`. - `expected` (integer, obligatorio) - `safe` (integer, obligatorio) - `help` (integer | null, obligatorio) - `wrong_point` (integer | null, obligatorio) - `pending` (integer | null, obligatorio) - `no_signal` (integer | null, obligatorio) - `not_worn` (integer | null, obligatorio) - `assembly_points` (array, obligatorio): Los destinos del tipo, descartados incluidos. - `id` (string (uuid), obligatorio) - `name` (string, obligatorio) - `status` (string, obligatorio) Valores: `available`, `excluded`. - `count` (integer | null, obligatorio): Personas a salvo en este punto. `null` en una evacuación cerrada: al cerrar solo se guarda el total. - `excluded` (object | null, obligatorio) ### Respuestas - `200`: Recibido. Cualquier `2xx` vale. ### Cuerpo de ejemplo ```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" } } } ``` ## Cambios en una evacuación Tu destino recibe un `POST` por cada evento `muster.*`. Va firmado; no lleva token. Un `POST` por evento a cada destino suscrito, con el objeto completo. Cabeceras: `X-SafeOnuba-Event-Id`, `X-SafeOnuba-Timestamp` (segundos Unix) y `X-SafeOnuba-Signature: v1=`. Rechaza más de 5 min de desfase. Durante una rotación llegan dos firmas separadas por coma. Acuse: cualquier `2xx` en menos de 5 s. Reintentos a los 10 s, 30 s, 2 min, 10 min, 30 min, 1 h y cada hora hasta 24 h. Entrega «al menos una vez» (deduplica por `id`) y **sin orden garantizado** (usa `date_last_modified`). SOS, caídas, «no puedo evacuar» y evacuaciones salen antes que el resto. ### Cuerpo (`application/json`, esquema `Event`) - `id` (string (uuid), obligatorio): Para deduplicar: la entrega es «al menos una vez». - `type` (EventType, obligatorio) Valores: `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, obligatorio) Valores: `2026-09-24`. - `created_at` (string (date-time), obligatorio) - `organization_id` (string (uuid) | null, obligatorio): La empresa del centro. `null` en los eventos de prueba. - `site_id` (string (uuid), obligatorio) - `data` ({ alert } | { muster }, obligatorio): El objeto completo tal como lo devolvería el `GET` en ese instante. - Variante 1: - `alert` (Alert, obligatorio) - `id` (string (uuid), obligatorio) - `type` (AlertType, obligatorio) Valores: `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, obligatorio) Valores: `low`, `medium`, `high`, `critical`. - `status` (AlertStatus, obligatorio) Valores: `unacknowledged`, `acknowledged`, `resolved`. - `title` (string, obligatorio): Con `include_worker_names`, el título que lee la sala (lleva el nombre de la persona). Sin él, uno compuesto con el tipo, la zona y el `external_id`, sin nombre. - `date_created` (string (date-time), obligatorio) - `date_last_modified` (string (date-time), obligatorio): Para ordenar: los webhooks no garantizan el orden, y lo más viejo que lo que ya se tiene se descarta. - `site` (SiteRef, obligatorio) - `id` (string (uuid), obligatorio) - `name` (string, obligatorio) - `zone` (ZoneRef, obligatorio) - `id` (string (uuid), obligatorio) - `name` (string, obligatorio) - `worker` (WorkerRef, obligatorio) - `id` (string (uuid), obligatorio) - `external_id` (string | null, obligatorio): Código del trabajador en la empresa (el de su sistema de personal). `null` si no se ha dado. - `name` (string): Nombre y apellidos. **Solo** si el contrato lo incluye; si no, la propiedad no aparece. - `contractor` (string | null, obligatorio) - `device` (DeviceRef, obligatorio) - `id` (string (uuid), obligatorio) - `serial` (string, obligatorio) - `battery` (integer | null, obligatorio) (de 0 a 100) - `date_last_seen` (string (date-time) | null, obligatorio) - `location` (Location, obligatorio) - `latitude` (number, obligatorio) (de -90 a 90) - `longitude` (number, obligatorio) (de -180 a 180) - `uncertainty_m` (number | null, obligatorio): Radio de incertidumbre en metros. `null` si no se conoce: no saberla no es tenerla perfecta. (mínimo 0) - `source` (string, obligatorio): `zone`: la persona estaba en una zona de privacidad y el punto es el de la zona, no el suyo. Valores: `gps`, `network`, `zone`. - `masked` (boolean, obligatorio): El punto es el representativo de una zona de privacidad. - `position_date_utc` (string (date-time), obligatorio) - `height` (object | null, obligatorio): Altura sobre el suelo por barómetro, cuando se puede afirmar; `null` si no. - `location_withheld` (boolean, obligatorio): La posición existe pero no se entrega: en «Solo en emergencia», una alerta por debajo de la gravedad que destapa no la enseña, tampoco en el panel. - `details` (AlertDetails, obligatorio) - Variante `SosDetails`: - `trigger` (string, obligatorio): Cómo se pidió: en la pantalla del reloj, con el botón físico, tras una caída sin respuesta o desde la sala. Valores: `watch_button`, `watch_hardware_button`, `after_fall`, `control_room`. - Variante `FallDetails`: - `escalated_to_sos` (boolean, obligatorio): No contestó al «¿Estás bien?» del reloj y pasó a SOS. - Variante `ZoneDetails`: - `zone_type` (string, obligatorio) Valores: `restricted`, `forbidden`. - `permit_reference` (string | null, obligatorio): El permiso de trabajo (ATS) que regía, si lo había. - Variante `HeatStressDetails`: - `action` (string, obligatorio): Lo que se le ha dicho en el reloj: parar, beber y buscar sombra. No hay niveles. Valores: `stop_and_rest`. - `heat_index_c` (number | null, obligatorio): Índice de calor de la planta (°C). - `sun_exposure_minutes` (integer | null, obligatorio) - Variante `EvacuationHelpDetails`: - `reason` (string | null, obligatorio): Lo que dijo en el reloj: atrapado, herido o ayudando a otra persona. Valores: `trapped`, `injured`, `helping`. - `muster_id` (string (uuid), obligatorio) - Variante `WorkerCallDetails`: - `voice_note` (boolean, obligatorio): Sin cobertura, el reloj grabó un aviso de voz (se escucha en el panel). - Variante `DeviceOfflineDetails`: - `minutes_silent` (integer, obligatorio) - Variante `LowBatteryDetails`: - `battery_pct` (integer, obligatorio) (de 0 a 100) - Variante `HazardDetails`: - `magnitude` (number | null, obligatorio) - `distance_km` (number | null, obligatorio) - `official` (boolean, obligatorio): `false`: estimación de SafeOnuba sin aviso oficial. `true`: boletín oficial confirmado por una persona. - `felt_at_site` (boolean, obligatorio) - `eta` (string (date-time) | null, obligatorio): Llegada estimada de la ola (tsunami). - `nearby` (array, obligatorio) - `kind` (string, obligatorio) Valores: `person`, `vehicle`. - `worker` (object, obligatorio) - `vehicle_type` (string | null, obligatorio) - `distance_m` (integer, obligatorio) - `reported_at` (string (date-time), obligatorio) - `vehicle_warnings` (array, obligatorio) - `at` (string (date-time), obligatorio) - `vehicle_type` (string | null, obligatorio) - `distance_m` (integer, obligatorio) - `worker_acknowledged_at` (string (date-time) | null, obligatorio) - `worker_acknowledged_at` (string (date-time) | null, obligatorio): El trabajador pulsó «Entendido» en su reloj (hora del reloj). - `acknowledged` (ActionBy, obligatorio) - `at` (string (date-time), obligatorio) - `by` (string, obligatorio): Quién: una persona del panel, o «Cliente · Operador (vía integración)». - `resolved` (object | null, obligatorio): Cerrada: quién, cuándo y con qué nota. - `at` (string (date-time), obligatorio) - `by` (string, obligatorio): Quién: una persona del panel, o «Cliente · Operador (vía integración)». - `note` (string | null, obligatorio) - `assigned_to` (string | null, obligatorio) - `simulated` (boolean, obligatorio): Generada en un simulacro o con relojes simulados. - `panel_url` (string (uri), obligatorio) - Variante 2: - `muster` (Muster, obligatorio) - `id` (string (uuid), obligatorio) - `site` (SiteRef, obligatorio) - `id` (string (uuid), obligatorio) - `name` (string, obligatorio) - `status` (string, obligatorio) Valores: `active`, `completed`, `cancelled`. - `phase` (string, obligatorio): `inspecting`: recuento completo y la sala revisa la planta; los relojes dicen «no vuelvas hasta nuevo aviso». Valores: `evacuating`, `inspecting`. - `kind` (MusterKind, obligatorio) Valores: `general`, `tsunami`, `earthquake`. - `reason` (string | null, obligatorio) - `started_at` (string (date-time), obligatorio) - `ended_at` (string (date-time) | null, obligatorio) - `deadline` (object | null, obligatorio) - `at` (string (date-time), obligatorio) - `source` (string, obligatorio): Tsunami: hora de un aviso oficial, o el techo del plan de la zona. Nunca un cálculo físico. Valores: `official`, `plan_estimate`. - `origin` (object | null, obligatorio): De dónde viene la emergencia. `null` en una evacuación sin origen (un simulacro general). - `latitude` (number, obligatorio) - `longitude` (number, obligatorio) - `radius_m` (integer, obligatorio) - `label` (string | null, obligatorio) - `zone` (ZoneRef, obligatorio) - `targets_revision` (integer, obligatorio): Sube cada vez que cambian los destinos (descarte, rehabilitación, cambio de tipo). - `totals` (object, obligatorio): Las mismas cifras que el banner del panel y el cierre: las cuenta el servidor. En una evacuación cerrada queda lo que se registró al cerrar —llegados y total—, y el resto sale `null`. - `expected` (integer, obligatorio) - `safe` (integer, obligatorio) - `help` (integer | null, obligatorio) - `wrong_point` (integer | null, obligatorio) - `pending` (integer | null, obligatorio) - `no_signal` (integer | null, obligatorio) - `not_worn` (integer | null, obligatorio) - `assembly_points` (array, obligatorio): Los destinos del tipo, descartados incluidos. - `id` (string (uuid), obligatorio) - `name` (string, obligatorio) - `status` (string, obligatorio) Valores: `available`, `excluded`. - `count` (integer | null, obligatorio): Personas a salvo en este punto. `null` en una evacuación cerrada: al cerrar solo se guarda el total. - `excluded` (object | null, obligatorio) ### Respuestas - `200`: Recibido. Cualquier `2xx` vale. ### Cuerpo de ejemplo ```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 · Referencia > Escenarios de prueba con relojes simulados, para integrar sin relojes. Solo en `sandbox.api.safeonuba.com`. Página: https://safeonuba.com/desarrolladores/referencia/sandbox · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. ## Lanzar un escenario de prueba `POST /v1/sandbox/scenarios` · cualquier token · [ver en la web](https://safeonuba.com/desarrolladores/referencia/sandbox#runSandboxScenario) **Solo en `sandbox.api.safeonuba.com`**; en producción, 404. Un escenario no inventa alertas: relojes simulados de un centro de sandbox mandan sus hechos y el motor de siempre decide, así que lo que llega —webhook, `GET`, recuento— es lo mismo que llegaría de un reloj de verdad, marcado `simulated: true`. Como mucho cinco abiertos a la vez por cliente y una evacuación por centro. ### Cuerpo (`application/json`, esquema `SandboxScenarioRequest`) - `scenario` (SandboxScenario, obligatorio) Valores: `sos`, `fall`, `heat_stress`, `evacuation`. - `site_id` (string (uuid)): El centro de sandbox. Sin él, el primero de los vuestros que sea de sandbox. ### Respuestas - `202`: Lanzado. → `SandboxScenarioRun` (`application/json`) - `401`: Falta el token, ha caducado o se ha revocado. → `Problem` (`application/problem+json`) - `403`: Al token le falta el scope que pide la ruta, o el centro no es del cliente. → `Problem` (`application/problem+json`) - `404`: No es el sandbox, o el centro no es vuestro. → `Problem` (`application/problem+json`) - `409`: Ya hay una evacuación en marcha en ese centro, o cinco escenarios abiertos. → `Problem` (`application/problem+json`) - `422`: El centro no es de sandbox, o tiene un reloj de verdad y no corre nada. → `Problem` (`application/problem+json`) - `429`: Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver. → `Problem` (`application/problem+json`) ### Ejemplo de petición (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" }' ``` ### Respuesta de ejemplo (`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" } ``` --- # Esquemas · Referencia > Los objetos que la API acepta y devuelve, campo a campo, tal como los define el contrato OpenAPI. Página: https://safeonuba.com/desarrolladores/referencia/esquemas · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar. ## Event Tipo: `object` - `id` (string (uuid), obligatorio): Para deduplicar: la entrega es «al menos una vez». - `type` (EventType, obligatorio) Valores: `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, obligatorio) Valores: `2026-09-24`. - `created_at` (string (date-time), obligatorio) - `organization_id` (string (uuid) | null, obligatorio): La empresa del centro. `null` en los eventos de prueba. - `site_id` (string (uuid), obligatorio) - `data` ({ alert } | { muster }, obligatorio): El objeto completo tal como lo devolvería el `GET` en ese instante. - Variante 1: - `alert` (Alert, obligatorio) - Variante 2: - `muster` (Muster, obligatorio) ## EventType Tipo: `string` `muster.updated`: el recuento ha cambiado (se comprueba cada 5 s, solo se emite si cambia). `muster.targets_changed`: la sala ha descartado o rehabilitado un punto de reunión. Valores: `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 Tipo: `object` - `id` (string (uuid), obligatorio) - `type` (AlertType, obligatorio) Valores: `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, obligatorio) Valores: `low`, `medium`, `high`, `critical`. - `status` (AlertStatus, obligatorio) Valores: `unacknowledged`, `acknowledged`, `resolved`. - `title` (string, obligatorio): Con `include_worker_names`, el título que lee la sala (lleva el nombre de la persona). Sin él, uno compuesto con el tipo, la zona y el `external_id`, sin nombre. - `date_created` (string (date-time), obligatorio) - `date_last_modified` (string (date-time), obligatorio): Para ordenar: los webhooks no garantizan el orden, y lo más viejo que lo que ya se tiene se descarta. - `site` (SiteRef, obligatorio) - `zone` (ZoneRef, obligatorio) - `worker` (WorkerRef, obligatorio) - `device` (DeviceRef, obligatorio) - `location` (Location, obligatorio) - `location_withheld` (boolean, obligatorio): La posición existe pero no se entrega: en «Solo en emergencia», una alerta por debajo de la gravedad que destapa no la enseña, tampoco en el panel. - `details` (AlertDetails, obligatorio) - `nearby` (array, obligatorio) - `vehicle_warnings` (array, obligatorio) - `worker_acknowledged_at` (string (date-time) | null, obligatorio): El trabajador pulsó «Entendido» en su reloj (hora del reloj). - `acknowledged` (ActionBy, obligatorio) - `resolved` (object | null, obligatorio): Cerrada: quién, cuándo y con qué nota. - `at` (string (date-time), obligatorio) - `by` (string, obligatorio): Quién: una persona del panel, o «Cliente · Operador (vía integración)». - `note` (string | null, obligatorio) - `assigned_to` (string | null, obligatorio) - `simulated` (boolean, obligatorio): Generada en un simulacro o con relojes simulados. - `panel_url` (string (uri), obligatorio) ## AlertType Tipo: `string` Qué ha pasado. `earthquake` y `tsunami_risk` son de la planta entera: no llevan `worker` ni `device`. Un vehículo cerca **no** es una alerta: sale dentro de la alerta de un incidente (`nearby`, `vehicle_warnings`). Valores: `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 Tipo: `string` Gravedad: `low` (información), `medium` (aviso), `high` (grave), `critical` (crítica: SOS, caída, «no puedo evacuar»). Valores: `low`, `medium`, `high`, `critical`. ## AlertStatus Tipo: `string` `acknowledged`: un operador se ha hecho cargo. No es lo mismo que `worker_acknowledged_at`, que es que el trabajador ha visto el aviso en su reloj. Valores: `unacknowledged`, `acknowledged`, `resolved`. ## SiteRef Tipo: `object` - `id` (string (uuid), obligatorio) - `name` (string, obligatorio) ## ZoneRef Tipo: `object | null` - `id` (string (uuid), obligatorio) - `name` (string, obligatorio) ## WorkerRef Tipo: `object | null` - `id` (string (uuid), obligatorio) - `external_id` (string | null, obligatorio): Código del trabajador en la empresa (el de su sistema de personal). `null` si no se ha dado. - `name` (string): Nombre y apellidos. **Solo** si el contrato lo incluye; si no, la propiedad no aparece. - `contractor` (string | null, obligatorio) ## DeviceRef Tipo: `object | null` - `id` (string (uuid), obligatorio) - `serial` (string, obligatorio) - `battery` (integer | null, obligatorio) (de 0 a 100) - `date_last_seen` (string (date-time) | null, obligatorio) ## Location Tipo: `object | null` - `latitude` (number, obligatorio) (de -90 a 90) - `longitude` (number, obligatorio) (de -180 a 180) - `uncertainty_m` (number | null, obligatorio): Radio de incertidumbre en metros. `null` si no se conoce: no saberla no es tenerla perfecta. (mínimo 0) - `source` (string, obligatorio): `zone`: la persona estaba en una zona de privacidad y el punto es el de la zona, no el suyo. Valores: `gps`, `network`, `zone`. - `masked` (boolean, obligatorio): El punto es el representativo de una zona de privacidad. - `position_date_utc` (string (date-time), obligatorio) - `height` (object | null, obligatorio): Altura sobre el suelo por barómetro, cuando se puede afirmar; `null` si no. - `height_m` (number, obligatorio) - `uncertainty_m` (number, obligatorio) - `building` (string | null, obligatorio) - `floor` (string | null, obligatorio) ## AlertDetails Tipo: `SosDetails | FallDetails | ZoneDetails | HeatStressDetails | EvacuationHelpDetails | WorkerCallDetails | DeviceOfflineDetails | LowBatteryDetails | HazardDetails` Depende de `type`. ## SosDetails Tipo: `object` - `trigger` (string, obligatorio): Cómo se pidió: en la pantalla del reloj, con el botón físico, tras una caída sin respuesta o desde la sala. Valores: `watch_button`, `watch_hardware_button`, `after_fall`, `control_room`. ## FallDetails Tipo: `object` - `escalated_to_sos` (boolean, obligatorio): No contestó al «¿Estás bien?» del reloj y pasó a SOS. ## ZoneDetails Tipo: `object` - `zone_type` (string, obligatorio) Valores: `restricted`, `forbidden`. - `permit_reference` (string | null, obligatorio): El permiso de trabajo (ATS) que regía, si lo había. ## HeatStressDetails Tipo: `object` Sin nada que salga del pulso: las constantes vitales no salen por la API, ni agregadas. - `action` (string, obligatorio): Lo que se le ha dicho en el reloj: parar, beber y buscar sombra. No hay niveles. Valores: `stop_and_rest`. - `heat_index_c` (number | null, obligatorio): Índice de calor de la planta (°C). - `sun_exposure_minutes` (integer | null, obligatorio) ## EvacuationHelpDetails Tipo: `object` - `reason` (string | null, obligatorio): Lo que dijo en el reloj: atrapado, herido o ayudando a otra persona. Valores: `trapped`, `injured`, `helping`. - `muster_id` (string (uuid), obligatorio) ## WorkerCallDetails Tipo: `object` - `voice_note` (boolean, obligatorio): Sin cobertura, el reloj grabó un aviso de voz (se escucha en el panel). ## DeviceOfflineDetails Tipo: `object` - `minutes_silent` (integer, obligatorio) ## LowBatteryDetails Tipo: `object` - `battery_pct` (integer, obligatorio) (de 0 a 100) ## HazardDetails Tipo: `object` - `magnitude` (number | null, obligatorio) - `distance_km` (number | null, obligatorio) - `official` (boolean, obligatorio): `false`: estimación de SafeOnuba sin aviso oficial. `true`: boletín oficial confirmado por una persona. - `felt_at_site` (boolean, obligatorio) - `eta` (string (date-time) | null, obligatorio): Llegada estimada de la ola (tsunami). ## Nearby Tipo: `object` Quién había a menos de 100 m cuando saltó una alerta crítica (SOS, caída). Distancia, nunca posición; quien estaba en una zona de privacidad no cuenta. - `kind` (string, obligatorio) Valores: `person`, `vehicle`. - `worker` (object, obligatorio) - `id` (string (uuid) | null, obligatorio): `null` en alertas anteriores al 24/09/2026, cuando aún no se copiaba. - `external_id` (string | null, obligatorio): Código del trabajador en la empresa (el de su sistema de personal). `null` si no se ha dado. - `name` (string): Nombre y apellidos. **Solo** si el contrato lo incluye; si no, la propiedad no aparece. - `contractor` (string | null, obligatorio) - `vehicle_type` (string | null, obligatorio) - `distance_m` (integer, obligatorio) - `reported_at` (string (date-time), obligatorio) ## VehicleWarning Tipo: `object` Avisos de vehículo cerca que recibió esa persona en la ventana del incidente. Son avisos, no alertas. - `at` (string (date-time), obligatorio) - `vehicle_type` (string | null, obligatorio) - `distance_m` (integer, obligatorio) - `worker_acknowledged_at` (string (date-time) | null, obligatorio) ## ActionBy Tipo: `object | null` - `at` (string (date-time), obligatorio) - `by` (string, obligatorio): Quién: una persona del panel, o «Cliente · Operador (vía integración)». ## Muster Tipo: `object` - `id` (string (uuid), obligatorio) - `site` (SiteRef, obligatorio) - `status` (string, obligatorio) Valores: `active`, `completed`, `cancelled`. - `phase` (string, obligatorio): `inspecting`: recuento completo y la sala revisa la planta; los relojes dicen «no vuelvas hasta nuevo aviso». Valores: `evacuating`, `inspecting`. - `kind` (MusterKind, obligatorio) Valores: `general`, `tsunami`, `earthquake`. - `reason` (string | null, obligatorio) - `started_at` (string (date-time), obligatorio) - `ended_at` (string (date-time) | null, obligatorio) - `deadline` (object | null, obligatorio) - `at` (string (date-time), obligatorio) - `source` (string, obligatorio): Tsunami: hora de un aviso oficial, o el techo del plan de la zona. Nunca un cálculo físico. Valores: `official`, `plan_estimate`. - `origin` (object | null, obligatorio): De dónde viene la emergencia. `null` en una evacuación sin origen (un simulacro general). - `latitude` (number, obligatorio) - `longitude` (number, obligatorio) - `radius_m` (integer, obligatorio) - `label` (string | null, obligatorio) - `zone` (ZoneRef, obligatorio) - `targets_revision` (integer, obligatorio): Sube cada vez que cambian los destinos (descarte, rehabilitación, cambio de tipo). - `totals` (object, obligatorio): Las mismas cifras que el banner del panel y el cierre: las cuenta el servidor. En una evacuación cerrada queda lo que se registró al cerrar —llegados y total—, y el resto sale `null`. - `expected` (integer, obligatorio) - `safe` (integer, obligatorio) - `help` (integer | null, obligatorio) - `wrong_point` (integer | null, obligatorio) - `pending` (integer | null, obligatorio) - `no_signal` (integer | null, obligatorio) - `not_worn` (integer | null, obligatorio) - `assembly_points` (array, obligatorio): Los destinos del tipo, descartados incluidos. ## MusterKind Tipo: `string` Valores: `general`, `tsunami`, `earthquake`. ## AssemblyPoint Tipo: `object` - `id` (string (uuid), obligatorio) - `name` (string, obligatorio) - `status` (string, obligatorio) Valores: `available`, `excluded`. - `count` (integer | null, obligatorio): Personas a salvo en este punto. `null` en una evacuación cerrada: al cerrar solo se guarda el total. - `excluded` (object | null, obligatorio) - `reason` (string, obligatorio) - `recommended` (boolean, obligatorio): Lo había recomendado el sistema (radio o viento). Quien descarta es siempre una persona. - `at` (string (date-time), obligatorio) - `by` (string, obligatorio) ## TokenResponse Tipo: `object` - `access_token` (string, obligatorio) - `token_type` (string, obligatorio) Valores: `Bearer`. - `expires_in` (number, obligatorio) Valores: `3600`. - `scope` (string, obligatorio) ## OAuthError Tipo: `object` Error del token, en el formato de RFC 6749 §5.2. - `error` (string, obligatorio) Valores: `invalid_request`, `invalid_client`, `unauthorized_client`, `unsupported_grant_type`, `invalid_scope`. - `error_description` (string) ## TokenRequest Tipo: `object` - `grant_type` (string, obligatorio) Valores: `client_credentials`. - `client_id` (string, obligatorio) - `client_secret` (string, obligatorio) - `scope` (string): Separados por espacios; subconjunto de los del cliente. Sin él, todos los del cliente. ## SiteList Tipo: `object` - `data` (array, obligatorio) ## Site Tipo: `object` - `id` (string (uuid), obligatorio) - `name` (string, obligatorio) - `location` (string | null, obligatorio) - `privacy_mode` (string, obligatorio): `on_demand` («Solo en emergencia»): la posición de una persona solo se ve con una alerta abierta desde `unveil_min_severity`, en evacuación o con una solicitud aprobada. La API obedece la misma regla que el panel. Valores: `continuous`, `on_demand`. - `unveil_min_severity` (Severity, obligatorio) Valores: `low`, `medium`, `high`, `critical`. ## Problem Tipo: `object` Error en formato RFC 9457 (`application/problem+json`), con el código HTTP real. - `type` (string, obligatorio): URI que identifica el tipo de problema. - `title` (string, obligatorio) - `status` (integer, obligatorio) - `detail` (string) - `instance` (string) - `code` (string, obligatorio): Código estable del error, para programar contra él. El texto puede cambiar; esto no. ## ZoneList Tipo: `object` - `data` (array, obligatorio) ## Zone Tipo: `object` - `id` (string (uuid), obligatorio) - `name` (string, obligatorio) - `type` (string, obligatorio) Valores: `work_area`, `restricted`, `forbidden`, `assembly_point`, `privacy`, `building`. - `geometry` (object | null, obligatorio): Polígono GeoJSON (WGS84). `null` en las zonas de privacidad: se da el nombre y el tipo, no dónde están (pendiente de validar con el comité). - `type` (string, obligatorio) Valores: `Polygon`. - `coordinates` (array>, obligatorio) - `assembly_point` (object | null, obligatorio): Solo en los puntos de reunión. - `serves` (array, obligatorio) Valores: `general`, `tsunami`, `earthquake`. - `elevation_m` (number | null, obligatorio) - `elevation_source` (string | null, obligatorio) Valores: `dem`, `verified`. - `vertical` (boolean, obligatorio): Refugio en altura (un edificio). - `capacity` (integer | null, obligatorio) ## AlertPage Tipo: `object` - `data` (array, obligatorio) - `next_cursor` (string | null, obligatorio): Cursor opaco para la página siguiente; `null` si no hay más. ## AlertUpdate Tipo: `object` Dos estados, `acknowledged` y `resolved`, más el operador. Reconocer autoasigna la alerta si nadie la llevaba, como en el panel. - `status` (string, obligatorio) Valores: `acknowledged`, `resolved`. - `resolution_reason` (string): Al cerrar: por qué. Texto libre; va a la cronología de la alerta junto a la nota. (hasta 200 caracteres) - `note` (string) (hasta 500 caracteres) - `actor` (Actor, obligatorio) ## Actor Tipo: `object` El operador de la sala del cliente que hace la acción. Obligatorio: «reconocida» mide la respuesta de una persona, y sin nombre la cronología diría solo el del cliente. - `name` (string, obligatorio) (hasta 120 caracteres) - `external_id` (string) (hasta 64 caracteres) ## MusterPage Tipo: `object` - `data` (array, obligatorio) - `next_cursor` (string | null, obligatorio): Cursor opaco para la página siguiente; `null` si no hay más. ## MissingWorkerList Tipo: `object` - `data` (array, obligatorio) ## MissingWorker Tipo: `object` - `worker` (WorkerRef, obligatorio) - `device` (DeviceRef, obligatorio) - `status` (string, obligatorio) Valores: `help`, `wrong_point`, `pending`, `no_signal`, `not_worn`. - `needs_rescue` (boolean, obligatorio): Tiene un SOS o una caída abierta y no ha llegado: no puede salir por su pie. - `help_reason` (string | null, obligatorio) Valores: `trapped`, `injured`, `helping`. - `assembly_point` (object | null, obligatorio): El punto donde está o dijo estar (con `wrong_point`, uno que no sirve). - `id` (string (uuid), obligatorio) - `name` (string, obligatorio) - `valid` (boolean, obligatorio) - `declared_at` (string (date-time) | null, obligatorio) - `last_seen_at` (string (date-time) | null, obligatorio) - `location` (Location, obligatorio) - `location_withheld` (boolean, obligatorio) ## WorkerPage Tipo: `object` - `data` (array, obligatorio) - `next_cursor` (string | null, obligatorio): Cursor opaco para la página siguiente; `null` si no hay más. ## Worker Tipo: `object` - `id` (string (uuid), obligatorio) - `external_id` (string | null, obligatorio): Código del trabajador en la empresa (el de su sistema de personal). `null` si no se ha dado. - `name` (string): Nombre y apellidos. **Solo** si el contrato lo incluye; si no, la propiedad no aparece. - `contractor` (string | null, obligatorio) - `role` (string | null, obligatorio) - `is_active` (boolean, obligatorio) - `device` (DeviceRef, obligatorio) - `date_last_seen` (string (date-time) | null, obligatorio): La más reciente entre la última posición y el último contacto del reloj. ## DevicePage Tipo: `object` - `data` (array, obligatorio) - `next_cursor` (string | null, obligatorio): Cursor opaco para la página siguiente; `null` si no hay más. ## Device Tipo: `object` - `id` (string (uuid), obligatorio) - `serial` (string, obligatorio) - `battery` (integer | null, obligatorio) (de 0 a 100) - `date_last_seen` (string (date-time) | null, obligatorio) - `model` (string | null, obligatorio) - `status` (string, obligatorio) Valores: `active`, `inactive`, `maintenance`, `lost`. - `worn` (boolean | null, obligatorio): `null`: no se sabe (sin sensor de muñeca). - `worker` (WorkerRef, obligatorio) ## PositionList Tipo: `object` - `data` (array, obligatorio) ## Position Tipo: `object` Solo las posiciones que el velo deja ver en ese instante: en «Solo en emergencia», quien no tiene nada abierto no aparece. - `device_id` (string (uuid), obligatorio) - `worker` (WorkerRef, obligatorio) - `location` (Location, obligatorio) - `in_vehicle` (boolean, obligatorio) ## EventPage Tipo: `object` - `data` (array, obligatorio) - `next_cursor` (string | null, obligatorio): Cursor opaco para la página siguiente; `null` si no hay más. ## WebhookEndpointList Tipo: `object` - `data` (array, obligatorio) ## WebhookEndpoint Tipo: `object` - `id` (string (uuid), obligatorio) - `url` (string (uri), obligatorio) - `event_types` (array, obligatorio) - `filters` (object, obligatorio) - `site_ids` (array) - `min_severity` (Severity) Valores: `low`, `medium`, `high`, `critical`. - `contractors` (array) - `is_active` (boolean, obligatorio) - `failing_since` (string (date-time) | null, obligatorio): Lleva fallando desde entonces. El destino **no** se desactiva solo: dejaría de llegar el siguiente SOS sin que nadie lo decida. - `created_at` (string (date-time), obligatorio) ## WebhookEndpointCreated Tipo: `object` - `id` (string (uuid), obligatorio) - `url` (string (uri), obligatorio) - `event_types` (array, obligatorio) - `filters` (object, obligatorio) - `site_ids` (array) - `min_severity` (Severity) Valores: `low`, `medium`, `high`, `critical`. - `contractors` (array) - `is_active` (boolean, obligatorio) - `failing_since` (string (date-time) | null, obligatorio): Lleva fallando desde entonces. El destino **no** se desactiva solo: dejaría de llegar el siguiente SOS sin que nadie lo decida. - `created_at` (string (date-time), obligatorio) - `secret` (string, obligatorio): El secreto de firma. **Solo se enseña esta vez.** ## WebhookEndpointCreate Tipo: `object` - `url` (string, obligatorio) - `event_types` (array, obligatorio) - `filters` (object) - `site_ids` (array) - `min_severity` (Severity) Valores: `low`, `medium`, `high`, `critical`. - `contractors` (array) ## WebhookSecretRotated Tipo: `object` - `secret` (string, obligatorio) - `previous_valid_until` (string (date-time), obligatorio) ## SandboxScenarioRun Tipo: `object` - `run_id` (string (uuid), obligatorio) - `scenario` (SandboxScenario, obligatorio) Valores: `sos`, `fall`, `heat_stress`, `evacuation`. - `site_id` (string (uuid), obligatorio) - `resource` (object, obligatorio): Lo que ha abierto: consultadlo con su `GET`, o esperad el webhook. - `type` (string, obligatorio) Valores: `alert`, `muster`. - `id` (string (uuid), obligatorio) - `ends_at` (string (date-time), obligatorio): Cuándo se cierra solo. ## SandboxScenario Tipo: `string` - `sos`, `fall`, `heat_stress`: una persona de prueba pulsa el SOS, se cae o sufre estrés térmico. Sale la alerta (`alert.created`) y, si nadie la cierra antes con `PUT /v1/alerts/{id}`, se cierra sola a los 10 min (`alert.resolved`). - `evacuation`: una evacuación general de 3 min. A los 15 s llegan cuatro personas al punto A; a los 35 s alguien llega al B y la sala lo descarta (`muster.targets_changed`); a los 55 s una persona pide ayuda (`alert.created`, `evacuation_help`); a los 95 s quien estaba en B se va al C; a los 3 min, todo despejado (`muster.ended`). Entre medias, `muster.updated` cada vez que cambia el recuento. Valores: `sos`, `fall`, `heat_stress`, `evacuation`. ## SandboxScenarioRequest Tipo: `object` - `scenario` (SandboxScenario, obligatorio) Valores: `sos`, `fall`, `heat_stress`, `evacuation`. - `site_id` (string (uuid)): El centro de sandbox. Sin él, el primero de los vuestros que sea de sandbox.