# 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<EventType | "alert.*" | "muster.*">, obligatorio)
- `filters` (object)
  - `site_ids` (array<string (uuid)>)
  - `min_severity` (Severity) Valores: `low`, `medium`, `high`, `critical`.
  - `contractors` (array<string>)

### 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=<hex(HMAC_SHA256(secreto, timestamp + "." + cuerpo))>`. 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<Nearby>, 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<VehicleWarning>, 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<AssemblyPoint>, 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=<hex(HMAC_SHA256(secreto, timestamp + "." + cuerpo))>`. 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<Nearby>, 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<VehicleWarning>, 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<AssemblyPoint>, 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"
    }
  }
}
```
