Webhooks

Webhooks

Webhooks are how Dominder tells your system that something changed. Every transition between "healthy" and "failing" for a domain fires a POST to your configured webhook URL, signed with your API key.

Setting your webhook URL

From youraccount page, setWebhook URL to a publicly reachable HTTPS endpoint on your service. A domain with no webhook URL configured simply won't fire any events.

Events

EventFired when
domain.failingA check transitions a domain'slastStatus tofailing from any other state (ok,warning, orunknown).
domain.recoveredA check takes the domain out offailing back took orwarning.
We only notify on transitions, not on every failing run. If a domain is failing for two hours, you get onedomain.failing event at the start and onedomain.recovered event when it clears. This is deliberately quiet - add your own "still failing" logic on top if you need it.

Request headers

POST https://your-app.example.com/dominder-webhook
Content-Type:            application/json
User-Agent:              Dominder-Webhook/1.0
X-Dominder-Event:        domain.failing
X-Dominder-Signature:    sha256=ab12cd34...
  • X-Dominder-Event - the event name, same asevent in the body.
  • X-Dominder-Signature - HMAC-SHA256 of the raw request body, keyed with your API key, hex-encoded, prefixed withsha256=. SeeVerifying the signature.

Payload shape

{
  "event":              "domain.failing",
  "domain": {
    "id":       "66a1c0ffeec0ffee12345678",
    "hostname": "example.com"
  },
  "status":              "failing",
  "previousStatus":      "ok",
  "consecutiveFailures": 1,
  "checkedAt":           "2026-04-22T12:39:00.000Z",
  "results": [
    { "kind": "status", "ok": true,  "message": "HTTPS 200" },
    { "kind": "ssl",    "ok": false, "message": "Certificate expired on 2026-04-21T23:59:59.000Z" },
    { "kind": "record", "ok": true,  "message": "A example.com -> 93.184.216.34 matches (93.184.216.34)" }
  ]
}
  • event - one ofdomain.failing,domain.recovered.
  • domain.hostname anddomain.id identify which domain changed.
  • status - the new aggregate status.
  • previousStatus - what it was before this check.
  • consecutiveFailures - number of failing runs in a row (always0 in adomain.recovered payload).
  • results - one entry per enabled check. SeeMonitoring checks for the possiblekind values and the shape of each message.

Verifying the signature

We sign the raw request body with an HMAC-SHA256 keyed by your API key. You shouldalways verify the signature before acting on a webhook - anyone can discover your webhook URL.

Read the raw body. If you hand the request to a JSON body-parser first and then re-serialize it for the HMAC, the byte sequence will differ (whitespace, key order) and the signature won't match. Capture the raw buffer before parsing.
Node.js (Express)
const express = require('express');
const crypto  = require('crypto');
const app = express();

app.post(
  '/dominder-webhook',
  express.raw({ type: 'application/json' }), // raw Buffer, not parsed
  (req, res) => {
    const header    = req.get('x-dominder-signature') || '';
    const signature = header.replace(/^sha256=/, '');
    const expected  = crypto
      .createHmac('sha256', process.env.DOMINDER_API_KEY)
      .update(req.body)
      .digest('hex');

    const ok =
      signature.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'));
    if (!ok) return res.status(401).send('bad signature');

    const payload = JSON.parse(req.body.toString('utf8'));
    // ... handle payload ...
    res.status(200).send('ok');
  }
);
Python (Flask)
import hmac, hashlib, os
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/dominder-webhook")
def hook():
    header   = request.headers.get("X-Dominder-Signature", "")
    signature = header.removeprefix("sha256=")
    expected = hmac.new(
        os.environ["DOMINDER_API_KEY"].encode("utf-8"),
        request.get_data(),
        hashlib.sha256,
    ).hexdigest()
    if not hmac.compare_digest(signature, expected):
        abort(401)
    payload = request.get_json()
    # ... handle payload ...
    return "ok"
PHP (plain)
$raw       = file_get_contents('php://input');
$header    = $_SERVER['HTTP_X_DOMINDER_SIGNATURE'] ?? '';
$signature = preg_replace('/^sha256=/', '', $header);
$expected  = hash_hmac('sha256', $raw, getenv('DOMINDER_API_KEY'));

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('bad signature');
}

$payload = json_decode($raw, true);
// ... handle payload ...

Best practices

  • Return 2xx quickly. Do your real work off the request thread (queue, background job). We treat anything outside 200-299 as a delivery failure.
  • Be idempotent. Usedomain.id +checkedAt as an idempotency key in case a request is delivered twice.
  • Validate the signature before parsing. Don't trust the body until you've verifiedX-Dominder-Signature.
  • Rotate the API key if a secret leaks. Since it's also the HMAC key, any leaked key lets an attacker forge webhooks to your endpoint.

Retries and delivery guarantees

Dominder attempts webhook delivery once per transition with a 10-second timeout. If delivery fails (non-2xx response, timeout, network error), we log it server-side but do not automatically retry. The next transition will trigger a new webhook.

If retries matter to you, run a cron that pollsGET /api/v1/domainsevery minute and reconciles against your own store - thelastStatus,lastCheckedAt, andlastFailureAtfields give you everything a webhook would.

Testing locally

Usewebhook.site orngrok to get a public URL that forwards to your laptop:

ngrok http 3000
# -> https://abcd-1234.ngrok-free.app

# set your webhook URL on /account to that tunnel + your path
# e.g. https://abcd-1234.ngrok-free.app/dominder-webhook

Then force a failure - for example, add a domain with an SSL check targetingexpired.badssl.com. Within one check cycle you should see the webhook arrive.