Tiempo real
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.
En esta página
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. |
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" }
}'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.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.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<JsonElement>();
// 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 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=<hex>. 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. |
type | El tipo de evento (ver Eventos). |
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. |
{
"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).
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:
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.
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.
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.
Gestionar los destinos
GET /v1/webhooks: tus destinos, con sus eventos, filtros,is_activeyfailing_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.