# Events

> Event types, the common envelope and how to recover missed events with /v1/events, which returns exactly what was sent by webhook.

Page: https://safeonuba.com/en/developers/events · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change.

An event is a change: an alert being created, an operator acknowledging it, a headcount going up. The same events arrive by [webhook](https://safeonuba.com/en/developers/webhooks) and can be requested again with `GET /v1/events`.

## Event types

| type | What it means |
| --- | --- |
| `alert.created` | An alert has been created. |
| `alert.acknowledged` | An operator has taken charge of the alert, from the dashboard or from your control room. |
| `alert.resolved` | The alert has been closed. |
| `alert.reopened` | A closed alert has been reopened. |
| `alert.worker_acknowledged` | The worker has seen the alert on their watch (`worker_acknowledged_at`). |
| `muster.started` | An evacuation has started. |
| `muster.kind_changed` | The evacuation type has changed (general, tsunami, earthquake). |
| `muster.phase_changed` | The phase has changed: `evacuating` or `inspecting`. |
| `muster.targets_changed` | The control room has ruled out or restored a muster point. |
| `muster.updated` | The headcount has changed. It is checked every 5 seconds and only emitted when it changes. |
| `muster.worker_missing` | Someone is missing. |
| `muster.ended` | The evacuation has ended. |

When you create a destination you can subscribe to whole groups (`alert.*`, `muster.*`) or to specific types.

## The envelope

Every event has the same shape:

| Field | What it is |
| --- | --- |
| `id` | Event identifier, for deduplication. |
| `type` | One of the types above. |
| `api_version` | `"2026-09-24"`. |
| `created_at` | When it happened. |
| `organization_id` | The site’s company. `null` in test events. |
| `site_id` | The site. |
| `data` | `{ "alert": … }` in `alert.*` events and `{ "muster": … }` in `muster.*` events. |

What goes inside `data` is **the complete object exactly as the `GET` would return it at that moment**: the whole alert, or the evacuation with its headcount and its muster points. It isn’t a “something changed, come and look” notice; it’s the state.

```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"
    }
  }
}
```

The full schema is in the reference: [`Event`](https://safeonuba.com/en/developers/reference/schemas#Event).

## Catching up on what you missed

If your receiver has been down, or you want to check you aren’t missing anything, `GET /v1/events` returns the events from the last 30 days **in publication order**, oldest first. They are exactly the same bytes that were sent by webhook, so you process them with the same code.

| Parameter | What it is |
| --- | --- |
| `since` | **Required.** From when, at most 30 days back. |
| `type` | A single event type only. |
| `limit`, `cursor` | Pagination (see [Pagination](https://safeonuba.com/en/developers/pagination)). |

Any scope will do.

```javascript
// Catches up from the last event you processed.
async function catchUp(since) {
  let cursor = null;
  do {
    const url = new URL('https://sandbox.api.safeonuba.com/v1/events');
    url.searchParams.set('since', since);
    url.searchParams.set('limit', '500');
    if (cursor) url.searchParams.set('cursor', cursor);

    const res = await fetch(url, { headers: { Authorization: `Bearer ${await safeOnubaToken()}` } });
    if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
    const page = await res.json();

    for (const event of page.data) await handleEvent(event); // the same code as the webhook; deduplicate by id
    cursor = page.next_cursor;
  } while (cursor);
}
```

```python
def catch_up(since: str) -> None:
    """Catches up from the last event you processed."""
    cursor = None
    while True:
        params = {"since": since, "limit": 500}
        if cursor:
            params["cursor"] = cursor
        res = requests.get(
            "https://sandbox.api.safeonuba.com/v1/events",
            headers={"Authorization": f"Bearer {safeonuba_token()}"},
            params=params,
            timeout=30,
        )
        res.raise_for_status()
        page = res.json()
        for event in page["data"]:
            handle_event(event)  # the same code as the webhook; deduplicate by id
        cursor = page["next_cursor"]
        if not cursor:
            break
```

```csharp
async Task CatchUp(string since)
{
    string? cursor = null;
    do
    {
        var url = $"https://sandbox.api.safeonuba.com/v1/events?since={Uri.EscapeDataString(since)}&limit=500"
                  + (cursor is null ? "" : $"&cursor={Uri.EscapeDataString(cursor)}");
        var res = await http.GetAsync(url);
        res.EnsureSuccessStatusCode();
        var page = await res.Content.ReadFromJsonAsync<JsonElement>();

        foreach (var ev in page.GetProperty("data").EnumerateArray())
            await HandleEvent(ev); // the same code as the webhook; deduplicate by id

        var next = page.GetProperty("next_cursor");
        cursor = next.ValueKind == JsonValueKind.Null ? null : next.GetString();
    } while (cursor is not null);
}
```

### When to use it

- **When your receiver starts up** after downtime, from the `created_at` of the last event you processed.
- **After a failed-delivery notice**: if a delivery runs out of retries, we email your technical contact. The event is still in `/v1/events`.
- **To fill gaps** if you notice you’re missing an intermediate step.

Since it’s the same “at least once” delivery, deduplicate by `id`: most likely some of what you recover you had already received.

> **Don’t use it instead of webhooks**
>
> Polling `/v1/events` in a loop arrives later than a webhook and uses up your [rate limits](https://safeonuba.com/en/developers/rate-limits). It’s the safety net, not the path.
