Skip to content

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.

Last updated May 9, 2026

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: POST or PUT, 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-Key echoing 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:

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 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

text
{
  "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:

text
{
  "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:

  • metric matches the available metrics catalog (e.g. cpu_percent, disk_percent, reboot_required, host_offline_seconds).
  • operator is one of gt, gte, lt, lte, eq, ne.
  • threshold_numeric and threshold_boolean are mutually exclusive — exactly one is non-null, depending on the metric type.
  • mount is set for disk_percent; interface is set for the network metrics. Both are null otherwise.
  • The same payload shape is sent on resolution; the event_uuid differs and value shows the resolution-time reading.

Verification: Node.js

text
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

text
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

text
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) or integration_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.