DomainOpsDomainOps

Webhooks

DomainOps can POST signed JSON to your endpoint whenever an event occurs — domain or SSL expiry warnings, exposure findings, liveness failures and recoveries. Webhooks use the same delivery infrastructure as our other notification channels (email, Slack, Teams) so they share the same retry semantics and signing scheme described below.

Configuring a webhook

Webhooks are configured per-user as a notification channel. Two ways to add one:

  • UI: Settings → Notifications → enable Webhook under either the Instant or Digest delivery section. Provide the receiving URL and an optional secret key.
  • API: PUT /api/v1/notifications/settings/webhook with { delivery_class: "instant", config: { webhook_url, secret_key } }. See the Notifications API for the full schema.

Event types

Each delivery carries an event_type:

  • whois — WHOIS-derived domain expiry warning
  • domain_expiry — explicit domain renewal alert (15 / 30 / 60 day windows)
  • ssl / ssl_expiry — SSL certificate renewal alert
  • liveness — endpoint liveness failure or recovery
  • exposure — security finding (subdomain takeover, CVE, DNS misconfig, etc.)

Payload

The default payload is JSON. Example:

json
{
  "event_type": "exposure",
  "severity": "critical",
  "category": "subdomain_takeover",
  "team": "Acme Corp",
  "user": "[email protected]",
  "domain_id": "9f1c8e12-…",
  "domain": "acme.com",
  "endpoint_id": "3b2d…",
  "endpoint_name": "marketing.acme.com",
  "details": {
    "provider": "AWS S3",
    "remediation": "Reclaim the bucket or remove the dangling CNAME."
  },
  "message": "Subdomain takeover risk detected on marketing.acme.com",
  "timestamp": "2026-04-28T09:00:00.000Z"
}

Fields like domain_id, endpoint_id and details are populated when the underlying event has them — for portfolio-level digest deliveries, the payload contains a list of items instead. Custom JSON templates are also supported (see the Notifications API).

Signature verification

If you provided a secret_key when configuring the webhook, every delivery includes an X-Signature header containing the HMAC-SHA256 of the raw request body, hex-encoded and prefixed with sha256=:

text
X-Signature: sha256=8a4c...c91e

Verifying in Node:

javascript
import crypto from 'node:crypto';

function verify(rawBody, signatureHeader, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');
  // constant-time compare — never use === on the full string
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signatureHeader)
  );
}

Verifying in Python:

python
import hmac, hashlib

def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(),
        raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature_header)

Verify against the raw bytes of the request body, before any JSON parsing or framework re-serialisation — that's what we sign on our side.

Delivery semantics

  • HTTP method: POST
  • Content-Type: application/json
  • Timeout: 30 seconds per attempt — slower receivers will be treated as transient failures
  • Success criterion: HTTP 2xx response
  • Retries: up to 3, with 1s / 2s / 4s exponential backoff between attempts
  • Transient errors (retried): 5xx, 408 (request timeout), 429 (rate limited), and network/connection errors
  • Permanent errors (no retry): all other 4xx responses — typically a misconfigured URL or a rejected payload on your side
  • At-least-once: retries mean you may receive the same event more than once. Idempotency on your side is recommended — use the timestamp + event identity to dedupe.

Testing

Use the Send test message button on Settings → Notifications to fire a synthetic event of each type. Combined with webhook.site or a local ngrok tunnel, this is the fastest way to inspect the payload before pointing at a production receiver.