Webhook Payloads and Signature Verification

Updated Oct 27, 2026

The full Domainyze webhook contract: event names, JSON payload shape per alert type, HTTP headers, HMAC-SHA256 signature verification, retries and security rules.

The webhook channel posts a JSON document to any https:// endpoint you control, signed so you can prove it came from us. This article is the reference: the exact payload for every event, the headers, and how to verify a signature correctly in the languages people usually ask about.

If you only want alerts in a chat app, use the purpose-built channels instead. They format the message for you: Slack, Discord.

Creating a webhook destination

  1. Open Settings, then the Alerts tab.
  2. Under "Add a channel", choose Webhook.
  3. Enter your https:// endpoint URL.
  4. Save. Your signing secret is displayed once, at that moment. Copy it now; it is stored hashed and cannot be shown again. If you lose it, use Rotate secret.

The request

Every delivery is an HTTP POST with a JSON body.

Header Value
Content-Type application/json
X-Webhook-Event The event name, for example domain.available
X-Webhook-Signature HMAC-SHA256 of the raw request body, hex encoded
User-Agent Domainyze-Webhook/1.0

Our connection timeout is 10 seconds. Any 2xx response is a success. Anything else is a failure and is retried.

Payload envelope

Every payload has the same three top-level keys:

{
    "event": "domain.status_changed",
    "timestamp": "2026-07-29T09:14:02+00:00",
    "data": {
        "domain": "example.com",
        "old_status": "unavailable",
        "new_status": "grace_period",
        "expiry_date": "2026-07-28T00:00:00+00:00",
        "checked_at": "2026-07-29T09:14:02+00:00"
    }
}

timestamp is when the event occurred, not when we sent it. Those differ by however long the delivery queue was backed up, and a receiver that orders events by send time will reorder them under load.

Events and their payloads

data keys are stable and always present for their event, using null when the value is genuinely unknown.

domain.available

The domain is now free to register. This is the highest-severity alert in the product.

{ "domain": "example.com", "status": "available", "expiry_date": null, "checked_at": "..." }

domain.status_changed

The registry lifecycle status moved.

{
    "domain": "example.com",
    "old_status": "unavailable",
    "new_status": "redemption_period",
    "expiry_date": "...",
    "checked_at": "..."
}

domain.expiry_digest

A per-user daily digest of domain renewal reminders: once a day, every domain that reached one of your configured reminder windows that day is rolled into a single delivery. data carries domain_count and a domains list rather than a single domain, so one digest covers a whole batch of renewals instead of one message per name.

{
    "domain_count": 2,
    "domains": [
        { "name": "example.com", "days": 30, "expiry_date": "...", "monitoring_type": "portfolio" },
        { "name": "example.net", "days": 7, "expiry_date": "...", "monitoring_type": "portfolio" }
    ]
}

ssl.expiry_digest

The certificate counterpart, same per-user daily digest shape. Each row names a certificate reaching a warning window that day.

{
    "domain_count": 1,
    "domains": [{ "name": "example.com", "days": 14, "cert_valid_to": "..." }]
}

ssl.invalid

{ "domain": "example.com", "reason": "certificate has expired" }

DNS changes arrive as one of three events, depending on what moved: dns.delegation_changed (nameservers), dns.email_auth_changed (mail and authentication), and dns.records_changed (everything else). All three share the same changes shape and are only sent after the change is confirmed by a second resolver.

changes is keyed by record type. Each entry carries three lists: added, removed, and changed, where a changed entry is an object with from and to, so a value edit is expressed as a single change rather than an unrelated removal and addition.

{
    "domain": "example.com",
    "changes": {
        "A": {
            "added": [],
            "removed": [],
            "changed": [{ "from": "192.0.2.50", "to": "192.0.2.100" }]
        }
    }
}

The classic record types carry full presentation lines, while SPF, DKIM and DMARC carry the record payload only, because that is the part that carries meaning for those types.

dns.delegation_changed

Your NS/SOA delegation moved. In addition to changes, the payload carries registry_mismatch, set to true when your live nameservers disagree with the ones your registrar has on record, which is a strong hijack signal a receiver can route on without parsing the diff.

{
    "domain": "example.com",
    "changes": {
        "NS": {
            "added": ["example.com.\t3600\tIN\tNS\tns1.newprovider.com."],
            "removed": ["example.com.\t3600\tIN\tNS\tns1.oldprovider.com."],
            "changed": []
        }
    },
    "registry_mismatch": true
}

dns.email_auth_changed

An MX/SPF/DKIM/DMARC record moved. Alongside changes, the payload carries grade (strong / adequate / weak / absent, the overall email-auth posture) and findings, the graded conclusions that became true with this change, so a DMARC policy dropping from p=reject to p=none reads as a dmarc_policy_none finding rather than as a raw string swap.

{
    "domain": "example.com",
    "changes": {
        "DMARC": {
            "added": [],
            "removed": [],
            "changed": [
                {
                    "from": "v=DMARC1; p=reject; rua=mailto:[email protected]",
                    "to": "v=DMARC1; p=none; rua=mailto:[email protected]"
                }
            ]
        }
    },
    "grade": "weak",
    "findings": [{ "code": "dmarc_policy_none", "severity": "high", "title": "DMARC is monitor-only" }]
}

dns.records_changed

Any other record change: website A/AAAA, CAA, verification TXT, and so on. Payload is domain plus changes.

{
    "domain": "example.com",
    "changes": {
        "CAA": {
            "added": ["example.com.\t3600\tIN\tCAA\t0 issue \"letsencrypt.org\""],
            "removed": [],
            "changed": []
        }
    }
}

dns.connectivity_failed

We repeatedly could not resolve the domain's DNS at all, which is different from its records changing.

{ "domain": "example.com", "check_type": "dns", "reason": "dns_resolver_error", "failure_count": 5 }

monitoring.auto_paused and monitoring.recovered

A check type was paused after sustained failures, or resumed after recovering.

{ "domain": "example.com", "check_type": "ssl", "reason": "domain_state" }

Verifying the signature

The signature is HMAC-SHA256 of the exact raw bytes of the request body, hex encoded, keyed with your signing secret.

The single most common mistake: parsing the JSON and then re-serializing it before hashing. json_encode and its equivalents make their own choices about escaping slashes, escaping unicode, and key spacing, so the bytes you produce will not always match the bytes we sent. It will pass in testing and fail intermittently in production, on exactly the payloads that contain a URL or a non-ASCII character. Hash the body as received, before any parsing.

Always compare using a constant-time function (hash_equals, crypto.timingSafeEqual, hmac.compare_digest) rather than ==.

PHP (Laravel)

$signature = $request->header('X-Webhook-Signature');
$expected = hash_hmac('sha256', $request->getContent(), $yourSecret);

if (! $signature || ! hash_equals($expected, $signature)) {
    abort(401);
}

Node.js (Express)

// Give this route the raw body, not the parsed one.
app.post("/webhooks/domainyze", express.raw({ type: "application/json" }), (req, res) => {
    const expected = crypto.createHmac("sha256", process.env.DOMAINYZE_SECRET).update(req.body).digest("hex");
    const received = req.get("X-Webhook-Signature") || "";

    const a = Buffer.from(expected);
    const b = Buffer.from(received);

    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
        return res.sendStatus(401);
    }

    const payload = JSON.parse(req.body.toString("utf8"));
    res.sendStatus(200);
});

Python (Flask)

expected = hmac.new(SECRET.encode(), request.get_data(), hashlib.sha256).hexdigest()

if not hmac.compare_digest(expected, request.headers.get("X-Webhook-Signature", "")):
    abort(401)

payload = request.get_json()

Writing a good receiver

Respond fast, process later. We time out after 10 seconds. Acknowledge with a 2xx as soon as you have verified the signature and stored the payload, and do the real work afterwards. A receiver that calls three APIs before responding will eventually time out and be retried, which turns one alert into several.

Be idempotent. Retries mean the same alert can arrive more than once. Use event plus timestamp plus data.domain as a natural key and ignore duplicates.

Fail loudly on unknown events. New event types are added over time. Log the ones you do not recognise rather than dropping them silently, then decide whether they matter.

Return the right status code. A 4xx tells us the request was bad; a 5xx tells us you are having trouble. Both are retried, but the delivery log shows them to you, and an endpoint that returns 200 for everything makes its own failures invisible.

Retries, rate limits and auto-disable

  • Failed deliveries are retried after roughly 10 seconds, 1 minute and 5 minutes, and keep retrying for up to 6 hours from the original event.
  • A 429 response with a Retry-After header is honoured exactly.
  • Webhook destinations are limited to 60 deliveries per minute, per destination.
  • After 20 consecutive failures, the destination is disabled automatically and you are notified. Re-enable it from the Alerts tab once your endpoint is healthy; the counter resets on the first success.
  • Every attempt is recorded in the destination's delivery log, and any delivery can be replayed from there. A replay reconstructs the original event exactly, so it is safe to use for backfilling after an outage on your side.

Security rules we enforce

  • HTTPS only, on standard ports.
  • SSRF protection. The hostname is resolved before the request and rejected if it points at a private, loopback or link-local address. The approved IP is then pinned for the connection itself, so the name cannot resolve to something else between the check and the request.
  • Certificate verification is always on. Self-signed certificates on your endpoint will fail.
  • Secrets are write-only. A signing secret is shown once at creation or rotation and never again.

More in Alerts And Notifications

Related guides and tutorials.

View all