Webhooks

Receive signed HTTPS notifications when your records and namespaces change.

Webhooks deliver real-time notifications to your own HTTPS endpoint whenever a record or namespace in your account is created, updated, or deleted. They are fed by the same transactional outbox that replicates your data to the DNS fleet, so a webhook fires for every change that reaches the resolvers.

Webhooks are a paid feature. The free tier may not register any endpoints (its webhook quota is 0); the Pro and Enterprise tiers unlock delivery.

Event Catalog

Every delivery names its event in the body and in the X-ResolveDB-Event header. The full set of customer-facing events is:

EventWhen it fires
record.upsertedA record was created or updated.
record.deletedA record was deleted (directly, or via a namespace cascade).
namespace.upsertedA namespace was created or updated.
namespace.deletedA namespace was deleted.

There is no separate created vs updated event — both map to *.upserted, because the delivered body is a snapshot of the resulting state. Internal events that carry authentication material (such as query-token lifecycle) are never delivered to a webhook.

You can fetch this catalog programmatically:

curl https://api.resolvedb.io/api/v1/webhooks/events \
  -H "Authorization: Bearer YOUR_API_KEY"
{
  "events": [
    { "name": "record.upserted",    "description": "Triggered when a record is created or updated" },
    { "name": "record.deleted",     "description": "Triggered when a record is deleted" },
    { "name": "namespace.upserted", "description": "Triggered when a namespace is created or updated" },
    { "name": "namespace.deleted",  "description": "Triggered when a namespace is deleted" }
  ]
}

Delivery Request

Each delivery is a single POST to your endpoint:

  • Method: POST
  • Scheme: https only (plaintext http endpoints are rejected at registration).
  • Content-Type: application/json
  • No redirects: a 3xx response is treated as a delivery failure, not followed.

Headers

HeaderDescription
X-ResolveDB-EventThe event name, e.g. record.upserted.
X-ResolveDB-DeliveryThe unique delivery id. Stable across retries of the same delivery — use it to deduplicate.
X-ResolveDB-SignatureThe HMAC signature: t=<unix_timestamp>,v1=<hex_hmac>. See Verifying Signatures.

Body

{
  "event": "record.upserted",
  "seq": 80421,
  "created_at": "2026-06-13T12:34:56Z",
  "data": {
    "key": "api-key.config.acme.v1",
    "namespace": "acme",
    "resource": "config",
    "version": "v1",
    "content_type": "application/json",
    "ttl_seconds": 300,
    "data": "eyJ0aGVtZSI6ImRhcmsifQ=="
  }
}
FieldDescription
eventThe event name (also in X-ResolveDB-Event).
seqA monotonically increasing sequence number. Deliveries for a single endpoint are enqueued in ascending seq order, and the body for seq=N is exactly the state at event N — even if the underlying record changed again before the retry window closed. Use it to order events and discard stale ones.
created_atISO 8601 timestamp of when the change occurred.
dataThe event payload (see below).

data for upsert events carries the record/namespace fields. data.data (for records) is the base64-encoded record value. For delete events data is reduced to identifiers:

{
  "event": "record.deleted",
  "seq": 80422,
  "created_at": "2026-06-13T12:35:10Z",
  "data": { "key": "api-key.config.acme.v1", "namespace": "acme" }
}
{
  "event": "namespace.deleted",
  "seq": 80423,
  "created_at": "2026-06-13T12:35:10Z",
  "data": { "namespace": "acme" }
}

The body never contains your customer id or any account identifier — only the namespace/record fields you own. Attribution to your account is resolved server-side before delivery.

Verifying Signatures

Every delivery is signed so you can confirm it genuinely came from ResolveDB and was not tampered with in transit.

Scheme. When you create a webhook, the API returns a secret once (store it securely — it cannot be retrieved again, only regenerated). The signature header is:

X-ResolveDB-Signature: t=<timestamp>,v1=<signature>

where <signature> is the lowercase hex HMAC-SHA256 over the exact string "<timestamp>.<raw_request_body>". The HMAC key is the lowercase hex SHA-256 digest of your webhook secret, not the raw secret itself:

signing_key = lowercase_hex(SHA256(secret))          # 64-char hex string
signature   = HMAC_SHA256(signing_key, timestamp + "." + raw_body)

Note the key derivation: ResolveDB stores only SHA256(secret) and signs with that digest, so you must hash your secret once (and use the hex string as the key) before computing the HMAC. Verifying with the raw secret will never match.

To verify a delivery:

  1. Read t and v1 from the X-ResolveDB-Signature header.
  2. Derive the signing key: signing_key = hex(SHA256(secret)).
  3. Recompute the HMAC over "<t>.<raw_body>" with that key. Use the raw request body bytes, before any JSON re-serialization.
  4. Compare your result to v1 using a constant-time comparison.
  5. Reject the delivery if they differ, or if t is too far from your current clock (we recommend a tolerance of ±5 minutes to limit replay).

Example: Node.js (Express)

import crypto from "node:crypto";
import express from "express";

const WEBHOOK_SECRET = process.env.RESOLVEDB_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;

// The signing key is the hex SHA-256 digest of your secret (not the raw secret).
const SIGNING_KEY = crypto
  .createHash("sha256")
  .update(WEBHOOK_SECRET)
  .digest("hex");

const app = express();

// Capture the RAW body — signature is over the exact bytes we received.
app.use(express.raw({ type: "application/json" }));

app.post("/resolvedb/webhook", (req, res) => {
  const header = req.get("X-ResolveDB-Signature") || "";
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("=")),
  );
  const timestamp = parts.t;
  const provided = parts.v1;
  if (!timestamp || !provided) return res.status(400).send("missing signature");

  // Reject stale/replayed deliveries.
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) {
    return res.status(400).send("timestamp out of tolerance");
  }

  const expected = crypto
    .createHmac("sha256", SIGNING_KEY)
    .update(`${timestamp}.`)
    .update(req.body) // req.body is a Buffer (raw bytes)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(provided);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).send("invalid signature");
  }

  const event = JSON.parse(req.body.toString("utf8"));
  // ... handle event.event / event.data, dedupe on X-ResolveDB-Delivery ...
  res.sendStatus(200);
});

Example: Python (Flask)

import hashlib
import hmac
import time

from flask import Flask, request, abort

WEBHOOK_SECRET = "<your-64-char-hex-secret>"
TOLERANCE_SECONDS = 300

# The signing key is the hex SHA-256 digest of your secret (not the raw secret).
SIGNING_KEY = hashlib.sha256(WEBHOOK_SECRET.encode()).hexdigest().encode()

app = Flask(__name__)


@app.post("/resolvedb/webhook")
def webhook():
    header = request.headers.get("X-ResolveDB-Signature", "")
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    timestamp, provided = parts.get("t"), parts.get("v1")
    if not timestamp or not provided:
        abort(400)

    if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
        abort(400)  # stale / replayed

    raw = request.get_data()  # raw bytes, not request.json
    expected = hmac.new(
        SIGNING_KEY, f"{timestamp}.".encode() + raw, hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected, provided):
        abort(401)

    event = request.get_json()
    # ... handle event["event"] / event["data"] ...
    return "", 200

Retries and Failure Handling

  • Delivery is at-least-once: a non-2xx response (or a connection/TLS error) is retried with exponential backoff, up to 5 attempts spanning roughly 25 minutes. Make your handler idempotent and dedupe on X-ResolveDB-Delivery.
  • A webhook is automatically disabled after a run of consecutive failed attempts. A single successful delivery resets the failure counter. Re-enable a disabled webhook from the dashboard or API once your endpoint is healthy.
  • Respond quickly (ideally within a couple of seconds) with a 2xx and process asynchronously; slow handlers count as timeouts and are retried.

Endpoint Requirements (SSRF Protection)

To protect both your account and the platform, registered URLs must point to a public HTTPS endpoint. ResolveDB rejects (at registration, on update, and again at each delivery):

  • non-https URLs and URLs with embedded credentials (https://user:pass@…);
  • localhost, *.localhost, *.local, and cloud metadata hostnames;
  • private, loopback, link-local, and unique-local IP ranges, including obfuscated forms (decimal/octal/hex literals and IPv4-mapped IPv6).

At delivery time the endpoint hostname is re-resolved and the connection is pinned to a validated public IP, so a hostname that later re-points to a private address (DNS rebinding) cannot be used to reach internal services.

Self-hosting note. ResolveDB is a managed service; the public-endpoint requirement is enforced for every customer and there is no allowlist override for internal/private targets. If you need to receive events on a private network, terminate the webhook at a public ingress (e.g. a reverse proxy or tunnel) that you control.

Managing Webhooks

Register and manage webhooks via the dashboard or the REST API. The secret is returned only on create and regenerate_secret:

# Create a webhook (returns the secret once)
curl -X POST https://api.resolvedb.io/api/v1/webhooks \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "webhook": {
          "name": "prod-listener",
          "url": "https://hooks.example.com/resolvedb",
          "events": ["record.upserted", "record.deleted"],
          "scopes": { "namespaces": ["acme"] }
        }
      }'

# Send a test delivery
curl -X POST https://api.resolvedb.io/api/v1/webhooks/WEBHOOK_ID/test \
  -H "Authorization: Bearer YOUR_API_KEY"

# Inspect recent deliveries (status, response code, errors)
curl https://api.resolvedb.io/api/v1/webhooks/WEBHOOK_ID/deliveries \
  -H "Authorization: Bearer YOUR_API_KEY"

# Rotate the secret (returns a new secret once; old signatures stop verifying)
curl -X POST https://api.resolvedb.io/api/v1/webhooks/WEBHOOK_ID/regenerate_secret \
  -H "Authorization: Bearer YOUR_API_KEY"

The optional scopes.namespaces array restricts a webhook to events for the listed namespaces; omit it to receive events for all of your namespaces.

Test deliveries (the /test endpoint) differ from real events: they carry the event type webhook.test (not in the Event Catalog) with a timestamp field instead of created_at, no seq, and a data object of { message, webhook_id, webhook_name }. They are signed identically, so they exercise your signature verification but not your real-event handler.

Next Steps