# 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<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](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=<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](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).
