PDNS Manager

Webhooks

Webhooks let external systems react automatically to DNS changes – e.g. a ChatOps bot, an internal audit backend, or a backup pipeline that snapshots after every zone change.

Create a webhook

Per user under Settings → API & Security → Webhooks → New webhook:

  • Name – display name.
  • URL – HTTPS endpoint that accepts the POST. Localhost / private IPs are blocked by default (SSRF protection).
  • Events – which events trigger. Default ["*"] = all.

On save the shared secret is shown once – use it to verify signatures on the receiver side.

Available events

EventWhen it fires
zone.importedA zone was created or replaced via import.
record.createdNew record (UI or API).
record.updatedRecord content or TTL changed.
record.deletedRecord removed.
record.bulkBulk operation (multiple create/delete in one call).

Payload

JSON body. Example for record.updated:

{
  "v": 1,
  "event": "record.updated",
  "timestamp": "2026-04-27T14:23:01.456Z",
  "app": "PDNS Manager",
  "actor_user_id": 17,
  "data": {
    "server": "master-fra1",
    "zone": "example.com.",
    "record": {
      "name": "www.example.com.",
      "type": "A",
      "ttl": 60,
      "old": [{"content": "203.0.113.10"}],
      "new": [{"content": "203.0.113.20"}]
    }
  }
}

Signature verification (mandatory!)

Each outgoing webhook request carries the header:

X-DNS-Manager-Signature: sha256=<hex>

The value is an HMAC-SHA256 over the raw JSON body bytes, signed with the webhook shared secret. Always verify on the receiver side – otherwise any kid with a script can fake webhooks.

Verify in Python

import hmac, hashlib

SECRET = b"your-webhook-secret"

def verify(raw_body: bytes, header_value: str) -> bool:
    if not header_value or not header_value.startswith("sha256="):
        return False
    sent = header_value.split("=", 1)[1].lower()
    calc = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(sent, calc)

Verify in Node.js

import crypto from "node:crypto";

const SECRET = "your-webhook-secret";

export function verify(rawBody, header) {
  if (!header?.startsWith("sha256=")) return false;
  const sent = header.slice("sha256=".length);
  const calc = crypto.createHmac("sha256", SECRET).update(rawBody).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(sent, "hex"), Buffer.from(calc, "hex"));
}

SSRF protection

Webhook URLs are validated before save and before each delivery. Blocked by default:

  • localhost, 127.0.0.0/8
  • ::1
  • RFC1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
  • Link-local (169.254.0.0/16, fe80::/10)
  • Multicast / broadcast

For internal setups (e.g. a webhook to an internal service in the same Compose net) there's an override:

# in .env
WEBHOOK_ALLOW_PRIVATE_URLS=true

Retry

Webhooks run as background tasks. Non-2xx responses are retried with backoff a few times. Permanent failures end up in the backend log – the panel shows the last status in the webhook list.

Rotate the secret

In the webhook list → Rotate secret. The old one stops working immediately, the new one is shown once.