# 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<JsonElement>();

        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.
