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.
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.
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:
POSTorPUT, 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
- Go to Integrations → Add Integration and choose Webhook.
- Enter the target URL. HTTPS in production.
- Pick the method —
POSTunless you have a reason. - Optionally paste a shared secret. Every payload is then signed with HMAC-SHA256 using it.
- 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
{
"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
{
"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"
}
metricis one of the values in the metrics catalog.operatorisgt,gte,lt,lte,eqorne.threshold_numericandthreshold_booleanare mutually exclusive — exactly one is non-null.mountis set fordisk_percent;interfaceis set for the network metrics; both are null otherwise.- The resolve payload has the same shape with a different
event_uuidand the value read at resolution time.
Signature verification
When a secret is configured, every request carries:
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.
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
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
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
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_uuidfor alert events, orintegration_uuidplustimestampfor test events. - Rotating the secret if you suspect a leak. Delete and recreate the integration; the plaintext is not retrievable after creation.
See also
- Notifications overview — what fires and what does not.
- Watch Owl alert rules — the live source of webhook events.
- Errors — the REST API's error envelope, which is a separate thing from these payloads.