Webhook payload signing
How StatusOwl signs outgoing webhook payloads with HMAC-SHA256, the events you can subscribe to today, and verification snippets for Node, Python, and Go.
When you configure a custom webhook integration with a shared secret, every outgoing payload is signed with HMAC-SHA256 so your receiver can verify the request actually came from StatusOwl and was not tampered with in transit. This page is the canonical reference for the signature format and the event payloads you'll receive.
At a glance
- Algorithm: HMAC-SHA256 over the raw request body, hex-encoded.
- Header:
X-StatusOwl-Signature: sha256=<hex>. - HTTP method:
POSTorPUT, configured per integration. - Content type:
application/json; charset=utf-8. - User agent:
StatusOwl-Webhook/1.0. - Retries: up to 3 attempts on network errors and non-2xx responses, 500 ms then 1 s backoff.
- SSRF protection: RFC1918, link-local, and cloud-metadata IP ranges are rejected at dispatch time, with no retry.
- Replay protection: none today (no timestamp header). Attach your own
Idempotency-Keyechoing if your downstream needs idempotent handling.
Currently-dispatchable events
The webhook dispatch path is currently live for two event sources:
integration.test— fired manually by the Send Test button in the dashboard. Use it to verify connectivity and signature handling.- Watch Owl alert events — fired by the Watch Owl alert engine when a host-metric rule fires or resolves, if the rule routes to a webhook integration.
Monitor state-change events (monitor.down, monitor.recovery,
monitor.maintenance) are configurable on each integration's
notify_on_* flags but are not yet dispatched to webhooks — they fire
through Email, Discord, Slack, Teams, and ntfy today. Webhook dispatch for
monitor state changes is in development; payload schemas will be added here
once they ship.
Signature format
When a webhook integration has a secret configured, every outgoing request
includes:
X-StatusOwl-Signature: sha256=<hex>
<hex> is the lowercase hex digest of HMAC-SHA256(secret, raw_body), where
raw_body is the exact bytes StatusOwl sent in the HTTP body.
If no secret is configured, the header is omitted. Always require the header in production — an unsigned request is indistinguishable from any other unauthenticated POST.
Verify the raw body, not a re-serialized JSON
Compute the HMAC over the bytes you actually received. Many web frameworks parse JSON eagerly and then discard the original buffer, so a re-serialized JSON body has different whitespace and key order than what was signed — the signature will not match. Configure your framework to keep the raw body (see the language-specific snippets below).
Event: 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-05-09T14:03:22.184Z",
"message": "StatusOwl webhook integration test"
}
Fired by Send Test in the dashboard. Receivers should treat
integration.test as a no-op signal — log it, return 200, take no
operational action.
Event: Watch Owl alert fire / resolve
When a Watch Owl alert rule fires or resolves and the rule routes to a webhook integration, the dispatched payload looks like:
{
"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-05-09T14:03:22.184Z",
"organization_uuid": "3f5b9c2a-1d8e-4f6c-a92d-7b3a8e1c4f02"
}
Field notes:
metricmatches the available metrics catalog (e.g.cpu_percent,disk_percent,reboot_required,host_offline_seconds).operatoris one ofgt,gte,lt,lte,eq,ne.threshold_numericandthreshold_booleanare mutually exclusive — exactly one is non-null, depending on the metric type.mountis set fordisk_percent;interfaceis set for the network metrics. Both are null otherwise.- The same payload shape is sent on resolution; the
event_uuiddiffers andvalueshows the resolution-time reading.
Verification: 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');
if (expected.length !== given.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(given, 'hex'),
);
}
Verification: 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)
Verification: 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 and delivery
If your endpoint returns a non-2xx response or the connection fails, StatusOwl retries up to 3 times total with 500 ms then 1 s backoff. After that the delivery is marked failed and the next event will start a fresh sequence. There is no exponential backoff beyond those two retries — design your receiver to be highly available, or wrap it with a queue.
If your handler is slow, return 200 immediately and process the event asynchronously. Webhooks aren't a request-reply transport — keep your sync path under a second.
SSRF protection
The dispatcher rejects target URLs that resolve to private or loopback IPs
(RFC1918, link-local 169.254/16, IPv6 ULA fc00::/7, cloud metadata
endpoints, multicast / broadcast). Only http:// and https:// schemes are
allowed (and http:// is only accepted in development; production
configurations require HTTPS). Redirects are not followed. Connection timeout
is 10 seconds.
If your webhook endpoint is on a private network, the dispatcher cannot reach it — front it with a public-facing relay or an HTTPS-tunnelled gateway.
Replay protection
The current signature scheme covers integrity, not freshness — there is no timestamp header today, so a captured request can be replayed if your endpoint accepts it. Mitigations:
- Use HTTPS endpoints only. TLS prevents on-path capture.
- Make handlers idempotent. Deduplicate by
event_uuid(Watch Owl alerts) orintegration_uuid + timestamp(test events) so a replayed request is a no-op. - Rotate the secret on suspected leak by deleting and recreating the integration. Plaintext secrets are not retrievable after creation.
A timestamp header with a tolerance window is on the roadmap — when it ships, this page will document the new header and the verification flow.
See also
- Custom webhook integration — setup, target URL configuration, retries, and SSRF protections from a customer perspective.
- Watch Owl alert rules — the live source of webhook events for host-metric thresholds.
- Errors — the StatusOwl REST API error envelope, separate from webhook payload shape.