Webhooks

Receive signed HTTPS notifications when your records and namespaces change.

Webhooks deliver eligible record and namespace changes to your HTTPS endpoint. They consume the same transactional outbox used for DNS replication, but event, namespace-scope, entitlement, and payload-size gates apply before a delivery is enqueued. Delivery-rate gating runs in the delivery job. Enabling webhook fanout starts at the current outbox position rather than replaying the historical backlog.

Webhooks are a paid feature. The free tier may not register any endpoints (its webhook quota is 0); Pro, Team, 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.com/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 dedicated signing_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 returned signing_secret itself:

signature = HMAC_SHA256(signing_secret, timestamp + "." + raw_body)

secret and signing_secret are separate values. Use signing_secret directly for HMAC verification; do not hash or otherwise derive a key from secret.

To verify a delivery:

  1. Read t and v1 from the X-ResolveDB-Signature header.
  2. Recompute the HMAC over "<t>.<raw_body>" with signing_secret. Use the raw request body bytes, before any JSON re-serialization.
  3. Compare your result to v1 using a constant-time comparison.
  4. 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 SIGNING_SECRET = process.env.RESOLVEDB_WEBHOOK_SIGNING_SECRET;
const TOLERANCE_SECONDS = 300;

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_SECRET)
    .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

SIGNING_SECRET = b"<your-64-char-hex-signing-secret>"
TOLERANCE_SECONDS = 300

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_SECRET, 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

  • Once an eligible delivery is enqueued, a non-2xx response or connection/TLS error is retried with exponential backoff, up to 5 attempts. Make your handler idempotent and dedupe on X-ResolveDB-Delivery.
  • Oversized delivery bodies and attempts above the per-webhook operational cap of 600 per hour are dropped rather than retried indefinitely.
  • 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 and HMAC key signing_secret are returned only on create and regenerate_secret; store signing_secret to verify deliveries:

# Create a webhook (returns secret and signing_secret once)
curl -X POST https://api.resolvedb.com/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.com/api/v1/webhooks/WEBHOOK_ID/test \
  -H "Authorization: Bearer YOUR_API_KEY"

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

# Rotate both values (returns them once; the old signing_secret stops verifying)
curl -X POST https://api.resolvedb.com/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