Webhooks

Receive WatchDog events
straight into your stack.

Verify HMAC-SHA256 signatures in Node, Python, or Ruby. Inspect the full payload shape for incident.acknowledged and incident.resolved. Know exactly when a webhook fires — and when it doesn't — with a copy-paste curl example for each event.

1. Signature verification

WatchDog signs every outgoing webhook with HMAC-SHA256 over the raw request body, encoded as hex. Two headers are sent:

  • X-Polsia-Signature — the hex digest, computed over the exact bytes of the request body. Re-stringifying after a JSON.parse will not reproduce the signature.
  • X-Polsia-Event — one of incident.acknowledged</code>, <code>incident.resolved, so you can branch on event type before parsing the body.

Subscribe in your dashboard to receive a 64-character hex secret (32 random bytes). Treat it like a password: store it in your secret manager, never commit it, and use a constant-time comparison when validating.

Node (Express)

// Express — keep the body raw so the signature matches the bytes WatchDog signed.
// app.post('/webhooks/watchdog', express.raw({ type: 'application/json' }), (req, res) => {
//   const rawBody = req.body; // Buffer, NOT a parsed object
//   const expected = crypto
//     .createHmac('sha256', process.env.WH_SECRET)
//     .update(rawBody)
//     .digest('hex');
//   const received = req.header('X-Polsia-Signature') || '';
//   const ok =
//     expected.length === received.length &&
//     crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
//   if (!ok) return res.status(401).send('bad signature');
//   const event = req.header('X-Polsia-Event');
//   const payload = JSON.parse(rawBody.toString('utf8'));
//   // ... handle event + payload here ...
//   res.status(200).send('ok');
// });

Python (Flask)

# Flask — keep the body raw so the signature matches the bytes WatchDog signed.
import hmac, hashlib
from flask import Flask, request, abort

app = Flask(__name__)
WH_SECRET = b"<your-64-char-hex-secret>"

@app.post("/webhooks/watchdog")
def watchdog():
    raw_body = request.get_data()  # bytes — do NOT json.loads first
    expected = hmac.new(WH_SECRET, raw_body, hashlib.sha256).hexdigest()
    received = request.headers.get("X-Polsia-Signature", "")
    if not hmac.compare_digest(expected, received):
        abort(401)
    event = request.headers.get("X-Polsia-Event")
    payload = request.get_json()
    # ... handle event + payload here ...
    return "ok", 200

Ruby (Sinatra)

# Sinatra — keep the body raw so the signature matches the bytes WatchDog signed.
require "openssl"
require "sinatra"
require "rack/utils"

WH_SECRET = "<your-64-char-hex-secret>"

post "/webhooks/watchdog" do
  raw_body = request.body.read  # string — do NOT JSON.parse first
  expected = OpenSSL::HMAC.hexdigest("SHA256", WH_SECRET, raw_body)
  received = request.env["HTTP_X_POLSIA_SIGNATURE"].to_s
  unless Rack::Utils.secure_compare(expected, received)
    halt 401, "bad signature"
  end
  event = request.env["HTTP_X_POLSIA_EVENT"]
  payload = JSON.parse(raw_body)
  # ... handle event + payload here ...
  "ok"
end

2. Payload schemas

Two events ship today. Both are sent with Content-Type: application/json and a User-Agent: WatchDog-Webhooks/1.

incident.acknowledged

Fired from the dashboard's Acknowledge button. Sent only once per incident (newly_acknowledged guard). The actor's display name is joined from the users table.

id  (integer)
    Incident ID.

monitor_id  (integer)
    Monitor this incident belongs to.

acknowledged_at  (ISO-8601 string)
    When the incident was acknowledged.

acknowledged_by_user_id  (UUID string)
    The user who clicked Acknowledge.

acknowledged_by_name  (string)
    Display name of the acknowledging user (joined from users).

monitor  (object)
    Snapshot of the monitor: { id, name, url }.
{
  "id": 4711,
  "monitor_id": 201,
  "acknowledged_at": "2026-08-08T14:32:11.000Z",
  "acknowledged_by_user_id": "e8d2a4c1-7b3c-4f99-9a52-91f7b6df7e22",
  "acknowledged_by_name": "Jordan Alekseev",
  "monitor": {
    "id": 201,
    "name": "Checkout API",
    "url": "https://api.example.com/checkout"
  }
}

incident.resolved

Fired when a check pass observes the monitor back online. downtime_ms is resolved_at − incident_started_at, floored at zero — the same value used by the recovery email, SMS, and push notifications.

id  (integer)
    Incident ID.

monitor_id  (integer)
    Monitor this incident belongs to.

resolved_at  (ISO-8601 string)
    When the monitor recovered (this check pass).

downtime_ms  (integer | null)
    resolved_at minus when the incident started. null if the start time could not be determined.

monitor  (object)
    Snapshot of the monitor: { id, name, url }.
{
  "id": 4711,
  "monitor_id": 201,
  "resolved_at": "2026-08-08T14:38:55.000Z",
  "downtime_ms": 404000,
  "monitor": {
    "id": 201,
    "name": "Checkout API",
    "url": "https://api.example.com/checkout"
  }
}

3. Delivery semantics

  • Attempts per event: 1. Each event is POSTed exactly once — there is no exponential backoff and no retry queue today. If your receiver returns non-2xx, times out, or is unreachable, the attempt is logged as failed and the dispatch is done.
  • Timeout: 10 seconds. The HTTPS request is destroyed on timeout; the attempt is logged as failed with an error message.
  • Success criterion: any HTTP status in the 2xx range. Anything else (including 3xx redirects, 4xx auth errors, 5xx upstream failures) is logged as failed.
  • Delivery log: every attempt — success or failure — appends one row to webhook_deliveries with the HTTP status code (or null on network error) and the error message. The dashboard surfaces this log per subscription so you can audit dropped events.
  • User-Agent: WatchDog-Webhooks/1, so you can allowlist incoming traffic at your receiver's edge if needed.

Plan your receiver accordingly: return 2xx as fast as you can (heavy work goes in a background queue), and add your own retry logic on the spots where you actually need it — WatchDog will not re-deliver on its own.

4. Curl examples

These snippets send a real body to your receiver and compute the X-Polsia-Signature header with openssl — so what you see is what WatchDog actually sends. Replace REPLACE_ME_WITH_YOUR_64_CHAR_HEX_SECRET with the secret from your subscription, and replace the receiver URL with your endpoint. The printf '%s' "$BODY" step is deliberate: it adds no trailing newline, which would change the bytes and break the signature.

incident.acknowledged

# 1. Save the secret WatchDog returned when you registered the subscription
export WH_SECRET='REPLACE_ME_WITH_YOUR_64_CHAR_HEX_SECRET'

# 2. Compute the signature over the EXACT body bytes you're about to send
BODY='{"id":4711,"monitor_id":201,"acknowledged_at":"2026-08-08T14:32:11.000Z","acknowledged_by_user_id":"e8d2a4c1-7b3c-4f99-9a52-91f7b6df7e22","acknowledged_by_name":"Jordan Alekseev","monitor":{"id":201,"name":"Checkout API","url":"https://api.example.com/checkout"}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WH_SECRET" | awk '{print $2}')

# 3. POST to your receiver
curl -X POST https://your-receiver.example.com/webhooks/watchdog \
  -H "Content-Type: application/json" \
  -H "X-Polsia-Event: incident.acknowledged" \
  -H "X-Polsia-Signature: $SIG" \
  --data "$BODY"

incident.resolved

# 1. Save the secret WatchDog returned when you registered the subscription
export WH_SECRET='REPLACE_ME_WITH_YOUR_64_CHAR_HEX_SECRET'

# 2. Compute the signature over the EXACT body bytes you're about to send
BODY='{"id":4711,"monitor_id":201,"resolved_at":"2026-08-08T14:38:55.000Z","downtime_ms":404000,"monitor":{"id":201,"name":"Checkout API","url":"https://api.example.com/checkout"}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WH_SECRET" | awk '{print $2}')

# 3. POST to your receiver
curl -X POST https://your-receiver.example.com/webhooks/watchdog \
  -H "Content-Type: application/json" \
  -H "X-Polsia-Event: incident.resolved" \
  -H "X-Polsia-Signature: $SIG" \
  --data "$BODY"

5. Try it now

Paste any URL you control (try webhook.site for a one-shot inbox) and we'll fire a real signed webhook at it — same HMAC-SHA256, same headers, same JSON shape Pro subscribers get in production. No signup, no secret management, no delivery log row. Pick up the signature and the throwaway secret below and verify locally with the verifyNode snippet above.