Webhooks

POST signed payloads to your incident system

Webhooks are the integration path for customers running their own incident system (PagerDuty, Opsgenie, BetterStack, a homemade Sinatra app). OpenSense POSTs a JSON payload on each event; you do whatever you want with it.

Setup

Account → Integrations → Webhooks → + Add:

  • URL: where we POST.
  • Events: which event kinds (alarm_opened, alarm_ack, alarm_cleared, device_offline, device_online, silence_opened, silence_closed, report_ready).
  • Secret: a long random string. We sign every payload with this.

One webhook destination per Solo subscription. Team tier raises to unlimited.

Payload

{
  "event": {
    "id":        "evt_4f3c",
    "kind":      "alarm_opened",
    "severity":  "alarm",
    "opened_at": "2026-05-17T08:10:00Z",
    "site_id":   "site_4f3c",
    "device_id": "dev_b1d2",
    "channel_id":"chn_a1b2",
    "value_at":   11.2,
    "thresholds": { "ok_min": -2, "ok_max": 8 }
  },
  "delivery": {
    "id":        "wh_4f3c1a",
    "attempt":    1,
    "delivered_at": "2026-05-17T08:10:01Z"
  }
}

Fields and shapes match the events API.

Signature

Each request carries:

  • X-OpenSense-Signature: t=1717248000,v1=base64(HMAC-SHA256(secret, t + "." + body))
  • X-OpenSense-Delivery-Id: a unique id for retry deduplication.
  • X-OpenSense-Timestamp: epoch seconds (same as t above).

Verify:

import hmac, hashlib, base64, time

def verify(secret: str, headers: dict, body: bytes) -> bool:
    sig = headers["X-OpenSense-Signature"]
    parts = dict(p.split("=", 1) for p in sig.split(","))
    t, v1 = parts["t"], parts["v1"]
    if abs(time.time() - int(t)) > 300:
        return False
    expected = base64.b64encode(
        hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).digest()
    ).decode()
    return hmac.compare_digest(v1, expected)

The 5-minute timestamp tolerance prevents replay; we reject on our side if our clock disagrees by more than 5 min with NTP.

Retry policy

ResultRetry?
2xxDone
3xxFollow up to 3 redirects
4xx (non-429)Drop. Log to Outbound. No retry.
429Respect Retry-After. Re-queue.
5xxExponential backoff: 5 s, 30 s, 5 min, 30 min, 1 h, 6 h. Then drop.
Timeout (> 5 s)Counts as 5xx

Drops are visible in the dashboard's Outbound log with the response body (first 4 KB). Investigate from there.

Idempotency

The X-OpenSense-Delivery-Id header lets your endpoint deduplicate. A retry of the same delivery uses the same id. Different events have different ids; an event acknowledged on retry is still a different event from the original alarm_opened.

What to do with the payload

For PagerDuty / Opsgenie: translate to their Events v2 format. The mapping is straightforward:

  • alarm_openedtrigger
  • alarm_ackacknowledge
  • alarm_clearedresolve

We will publish per-vendor adapter snippets on GitHub once the client base for each grows past a handful.

For homemade systems: idempotency by delivery_id, signature verification, then whatever business logic.

What you give up

  • No custom payload shape. The schema is the schema. Use a transform proxy if you need a different shape.
  • Single webhook per site. Team tier raises to multiple per site.
  • No mutual TLS. Endpoint is plain HTTPS with bearer-style HMAC. mTLS may come later if customers ask.