# Webhooks

> Create a destination, understand the format of each delivery, respond in time, deduplicate, filter by site or severity and send a test event.

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

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](https://safeonuba.com/en/developers/webhooks#filters). |

```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 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.
```

```python
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.
```

```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 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](https://safeonuba.com/en/developers/webhooks/verify-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](https://safeonuba.com/en/developers/webhooks#duplicates). |
| `type` | The event type (see [Events](https://safeonuba.com/en/developers/events#event-types)). |
| `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. |

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

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](https://safeonuba.com/en/developers/events#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:

```sql
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`.

```javascript
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](https://safeonuba.com/en/developers/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](https://safeonuba.com/en/developers/webhooks/verify-signature#rotating-the-secret).

## Managing destinations

- `GET /v1/webhooks`: your destinations, with their events, filters, `is_active` and `failing_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](https://safeonuba.com/en/developers/rate-limits).
