Real time
Events
Event types, the common envelope and how to recover missed events with /v1/events, which returns exactly what was sent by webhook.
An event is a change: an alert being created, an operator acknowledging it, a headcount going up. The same events arrive by webhook 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.
{
"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.
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). |
Any scope will do.
// 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);
}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:
breakasync 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_atof 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.