v1 · preview

Try webhook signature, alert.created, location_withheld or 429.

↑ ↓ moveEnter openEsc close

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.

FieldRequiredWhat it is
urlYesYour receiver. https only, and at a public address.
event_typesYesThe events: whole groups (alert.*, muster.*) or specific types (alert.created).
filtersNoWhich 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" }
  }'

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:

HeaderWhat it carries
X-SafeOnuba-Event-IdThe event’s id.
X-SafeOnuba-TimestampWhen it was signed, in Unix seconds.
X-SafeOnuba-Signaturev1=<hex>. During a secret rotation, two signatures separated by a comma.
User-AgentSafeOnuba-Webhooks/2026-09-24

The body is always the same envelope:

FieldWhat it is
idEvent identifier. It’s the one you use to deduplicate.
typeThe event type (see Events).
api_version"2026-09-24".
created_atWhen it happened, in ISO 8601 UTC.
organization_idThe site’s company. null in test events.
site_idThe site.
data{ "alert": … } or { "muster": … }: the complete object, exactly as the GET would return it at that moment.
alert.created
{
  "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:

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.
Node
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:

FilterWhat it does
site_idsOnly those sites.
min_severityOnly alerts of that severity or higher (low, medium, high, critical).
contractorsOnly 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

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.