Real time
Webhooks
Create a destination, understand the format of each delivery, respond in time, deduplicate, filter by site or severity and send a test event.
On this page
With a webhook, SafeOnuba tells your server as soon as something happens: a new alert, an operator taking charge, a change in an evacuation’s headcount. Your control room doesn’t have to ask, and the alert reaches it in a second or two.
Create a destination
A destination is a URL of yours and the events you want to receive on it. You create it with POST /v1/webhooks and the webhooks:manage scope.
| Field | Required | What it is |
|---|---|---|
url | Yes | Your receiver. https only, and at a public address. |
event_types | Yes | The events: whole groups (alert.*, muster.*) or specific types (alert.created). |
filters | No | Which part of those events: by site, severity or contractor. See Filters. |
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 safeOnubaToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: 'https://sala.cliente.example/safeonuba',
event_types: ['alert.*', 'muster.*'],
filters: { min_severity: 'high' },
}),
});
const destination = await res.json();
// destination.secret only arrives this once: store it now.res = requests.post(
"https://sandbox.api.safeonuba.com/v1/webhooks",
headers={"Authorization": f"Bearer {safeonuba_token()}"},
json={
"url": "https://sala.cliente.example/safeonuba",
"event_types": ["alert.*", "muster.*"],
"filters": {"min_severity": "high"},
},
timeout=10,
)
res.raise_for_status()
destination = res.json()
# destination["secret"] only arrives this once: store it now.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 destination = await res.Content.ReadFromJsonAsync<JsonElement>();
// destination.GetProperty("secret") only arrives this once: store it now.The response is the destination with one more field, secret, which starts with whsec_. It is only shown this once. It is the secret you use to verify the signature of each delivery.
Delivery format
Each event is a POST to your URL with a JSON body and these headers:
| Header | What it carries |
|---|---|
X-SafeOnuba-Event-Id | The event’s id. |
X-SafeOnuba-Timestamp | When it was signed, in Unix seconds. |
X-SafeOnuba-Signature | v1=<hex>. During a secret rotation, two signatures separated by a comma. |
User-Agent | SafeOnuba-Webhooks/2026-09-24 |
The body is always the same envelope:
| Field | What it is |
|---|---|
id | Event identifier. It’s the one you use to deduplicate. |
type | The event type (see Events). |
api_version | "2026-09-24". |
created_at | When it happened, in ISO 8601 UTC. |
organization_id | The site’s company. null in test events. |
site_id | The site. |
data | { "alert": … } or { "muster": … }: the complete object, exactly as the GET would return it at that moment. |
{
"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"
}
}
}Since data carries the whole object, you normally don’t need to call the API when you receive an event: you have everything your control room needs to display it.
Responding in time
A delivery counts as delivered when your receiver responds with any 2xx in under 5 seconds. Redirects aren’t followed: a 301 or a 302 is a failure.
That’s why your receiver should do the bare minimum: verify the signature, put the event on a queue (a table, Redis, RabbitMQ, whatever you already use) and respond. Process it afterwards, outside the request. If your control-room system is slow or goes down, the queue holds and SafeOnuba doesn’t retry for nothing.
Retries
If your receiver doesn’t respond 2xx in time, the delivery is retried after 10 s, 30 s, 2 min, 10 min, 30 min and 1 h, and then every hour up to 24 hours from the event.
If a delivery is given up as lost, we email your company’s technical contact. The event remains available in GET /v1/events for 30 days (see Catching up on what you missed).
A destination that fails doesn’t switch itself off: if it did, the next SOS would stop arriving without anyone having decided that. Instead, failing_since tells you how long it has been failing. Keep an eye on it with GET /v1/webhooks.
Duplicates
Delivery is “at least once”: the same event can arrive more than once, for example if your receiver processed it but the response didn’t arrive in time. Deduplicate by the event’s id (it also comes in X-SafeOnuba-Event-Id).
The simplest way is a table with the id as its key:
CREATE TABLE safeonuba_events (
id uuid PRIMARY KEY,
received_at timestamptz NOT NULL DEFAULT now()
);
-- Returns a row if the event is new, none if it had already been received.
INSERT INTO safeonuba_events (id) VALUES ($1)
ON CONFLICT (id) DO NOTHING
RETURNING id;Order
Delivery order isn’t guaranteed. The urgent goes first: SOS, falls, “I can’t evacuate” and evacuations jump ahead of the rest. And a retry can arrive after a newer event.
Each event carries the complete object, so the rule is to keep the most recent and discard whatever arrives older:
- To order events relative to each other, use their
created_at. - For an alert, compare
date_last_modified: if what arrives is older than what you already have, discard it. - If you detect a gap, for example after an outage, fill it with
GET /v1/events.
const latestVersion = new Map(); // alert id → date_last_modified
function applyAlert(alert) {
const known = latestVersion.get(alert.id);
if (known && Date.parse(known) >= Date.parse(alert.date_last_modified)) return; // older
latestVersion.set(alert.id, alert.date_last_modified);
showInControlRoom(alert);
}Filters
With filters you receive only a part of the events you asked for:
| Filter | What it does |
|---|---|
site_ids | Only those sites. |
min_severity | Only alerts of that severity or higher (low, medium, high, critical). |
contractors | Only what affects those contractors. |
You can have several destinations with different filters: for example, the critical alerts from every plant to the central control room, and everything from one plant to its own control room.
Sending a test
POST /v1/webhooks/{id}/test sends that destination an alert.created marked simulated: true, signed like a real one. It responds 202 when it queues it; you see the result on the destination, in failing_since.
To test the whole journey, with real alerts and evacuations raised by simulated watches, use the sandbox.
Rotating the secret
POST /v1/webhooks/{id}/rotate-secret gives you a new secret. The previous one keeps signing for 24 hours, and during that time each delivery carries both signatures. How to switch secrets without losing any event: Verifying a webhook signature.
Managing destinations
GET /v1/webhooks: your destinations, with their events, filters,is_activeandfailing_since.DELETE /v1/webhooks/{id}: deletes a destination.
Webhooks or polling
Use webhooks to find out what’s happening, and the API for the rest: loading the state at start-up, the list of who is missing, acknowledging an alert. Polling in a loop arrives later than a webhook and uses up your rate limits.