Skip to content

Webhook

Send signed JSON to any HTTPS endpoint you control — payload shapes, HMAC-SHA256 signature verification in Node, Python and Go, retries and SSRF protection.

Last updated

The webhook integration POSTs (or PUTs) JSON to an HTTPS endpoint you control. Use it to reach a tool StatusOwl does not support natively — a ticketing system, an on-call platform, an internal dashboard, a remediation script.

This page is the complete reference: setup, payload shapes, signature verification, retry behaviour and the SSRF rules.

This is an outbound notification channel, not an API subscription

StatusOwl does not offer webhooks that tell you when a status page, an incident or a monitor changes. There is no event subscription API. This integration is one of the six notification channels, and it carries exactly the same events the other five do. Webhooks-out for page state are on the roadmap.

At a glance

  • Method: POST or PUT, chosen per integration.
  • Content type: application/json; charset=utf-8.
  • User agent: StatusOwl-Webhook/1.0.
  • Signature: X-StatusOwl-Signature: sha256=<hex>, when a secret is set.
  • Retries: up to 3 attempts, 500 ms then 1 s backoff.
  • Replay protection: none today — there is no timestamp header.

Setting it up

  1. Go to Integrations → Add Integration and choose Webhook.
  2. Enter the target URL. HTTPS in production.
  3. Pick the method — POST unless you have a reason.
  4. Optionally paste a shared secret. Every payload is then signed with HMAC-SHA256 using it.
  5. Click Save, then Send Test to verify your receiver before you rely on it.

What arrives here today

  • integration.test — fired by Send Test. Use it to exercise your signature verification and your retry path.
  • Watch Owl alert rules routed to this integration, on fire and on resolve.

Nothing else dispatches. A failing HTTP, ping or TCP check does not POST here — see Notifications overview.

Payload: integration.test

json
{
  "event": "integration.test",
  "integration_uuid": "9a0c1f3b-7d4e-4a92-b2f8-1e5c7a3d2f01",
  "integration_name": "On-call automation",
  "organization_uuid": "3f5b9c2a-1d8e-4f6c-a92d-7b3a8e1c4f02",
  "timestamp": "2026-08-05T14:03:22.184Z",
  "message": "StatusOwl webhook integration test"
}

Treat integration.test as a no-op: log it, return 200, take no operational action.

Payload: Watch Owl alert fire / resolve

json
{
  "event_uuid": "de31a4fd-a1b1-4f0e-94a3-9a4d3a3c00bb",
  "rule_uuid": "1f2a3b4c-5d6e-7f80-9a0b-1c2d3e4f5a6b",
  "rule_name": "prod-db disk near full",
  "host_uuid": "abc12300-0001-4000-a000-000000000001",
  "host_hostname": "prod-db-01.internal",
  "metric": "disk_percent",
  "operator": "gt",
  "threshold_numeric": 90,
  "threshold_boolean": null,
  "value": 94.2,
  "mount": "/var/lib/postgresql",
  "interface": null,
  "fired_at": "2026-08-05T14:03:22.184Z",
  "organization_uuid": "3f5b9c2a-1d8e-4f6c-a92d-7b3a8e1c4f02"
}
  • metric is one of the values in the metrics catalog.
  • operator is gt, gte, lt, lte, eq or ne.
  • threshold_numeric and threshold_boolean are mutually exclusive — exactly one is non-null.
  • mount is set for disk_percent; interface is set for the network metrics; both are null otherwise.
  • The resolve payload has the same shape with a different event_uuid and the value read at resolution time.

Signature verification

When a secret is configured, every request carries:

text
X-StatusOwl-Signature: sha256=<hex>

<hex> is the lowercase hex digest of HMAC-SHA256(secret, raw_body), where raw_body is the exact bytes sent. With no secret configured the header is omitted — in production, require it, because an unsigned request is indistinguishable from any other anonymous POST.

Sign the raw bytes, not a re-serialized object

Most frameworks parse JSON eagerly and discard the original buffer. Re-serializing gives different whitespace and key order, and the signature will not match. Configure your framework to keep the raw body.

Node.js

javascript
import crypto from 'node:crypto';
import express from 'express';

const app = express();

// Keep the raw body — Express's JSON parser discards it by default.
app.post('/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.header('X-StatusOwl-Signature');
    if (!verify(req.body, signature, process.env.STATUSOWL_WEBHOOK_SECRET)) {
      return res.status(401).json({ error: 'invalid signature' });
    }
    const event = JSON.parse(req.body.toString('utf8'));
    // ... handle event ...
    res.status(200).json({ ok: true });
  },
);

function verify(rawBody, headerValue, secret) {
  if (!headerValue?.startsWith('sha256=')) return false;
  const given = headerValue.slice('sha256='.length);
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');
  // Lengths must match before timingSafeEqual
  if (expected.length !== given.length) return false;
  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(given, 'hex'),
  );
}

Python

python
import hmac
import hashlib
import os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ['STATUSOWL_WEBHOOK_SECRET'].encode()

@app.post('/webhook')
def webhook():
    signature = request.headers.get('X-StatusOwl-Signature', '')
    raw_body = request.get_data()  # raw bytes, before JSON parsing

    if not verify(raw_body, signature, SECRET):
        abort(401, 'invalid signature')

    event = request.get_json()
    # ... handle event ...
    return {'ok': True}, 200


def verify(raw_body: bytes, header_value: str, secret: bytes) -> bool:
    if not header_value.startswith('sha256='):
        return False
    given = header_value[len('sha256='):]
    expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, given)

Go

go
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "os"
    "strings"
)

var secret = []byte(os.Getenv("STATUSOWL_WEBHOOK_SECRET"))

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    raw, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "read failed", http.StatusBadRequest)
        return
    }
    if !verify(raw, r.Header.Get("X-StatusOwl-Signature"), secret) {
        http.Error(w, "invalid signature", http.StatusUnauthorized)
        return
    }
    // ... json.Unmarshal(raw, &event) and handle ...
    w.WriteHeader(http.StatusOK)
}

func verify(rawBody []byte, headerValue string, secret []byte) bool {
    if !strings.HasPrefix(headerValue, "sha256=") {
        return false
    }
    given, err := hex.DecodeString(strings.TrimPrefix(headerValue, "sha256="))
    if err != nil {
        return false
    }
    mac := hmac.New(sha256.New, secret)
    mac.Write(rawBody)
    return hmac.Equal(mac.Sum(nil), given)
}

Retries

A non-2xx response or a connection failure is retried up to 3 attempts total, with 500 ms then 1 s backoff. After that the delivery is marked failed and the next event starts fresh. There is no longer-tail backoff — if your receiver needs to be slow, return 200 immediately and process asynchronously.

Per-delivery status (sent, failed, skipped) is recorded against the alert event, so you can confirm from the dashboard whether a notification actually left.

SSRF protection

The target URL is validated both when you save it and again at dispatch time. Rejected: RFC1918 ranges, loopback, link-local 169.254.0.0/16, IPv6 ULA fc00::/7, multicast, broadcast, and cloud metadata endpoints. Only http and https schemes are allowed, and plain http only outside production. Redirects are not followed. Connection timeout is 10 seconds.

A blocked request fails immediately with no retry. An endpoint on a private network cannot be reached — front it with a public relay.

Replay protection

The signature covers integrity, not freshness. There is no timestamp header, so a captured request can be replayed against an endpoint that accepts it. Mitigate by:

  • Using HTTPS only, so there is nothing on-path to capture.
  • Making handlers idempotent — deduplicate on event_uuid for alert events, or integration_uuid plus timestamp for test events.
  • Rotating the secret if you suspect a leak. Delete and recreate the integration; the plaintext is not retrievable after creation.

See also