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:
| Event | When it fires |
|---|---|
record.upserted | A record was created or updated. |
record.deleted | A record was deleted (directly, or via a namespace cascade). |
namespace.upserted | A namespace was created or updated. |
namespace.deleted | A 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:
httpsonly (plaintexthttpendpoints are rejected at registration). Content-Type:application/json- No redirects: a
3xxresponse is treated as a delivery failure, not followed.
Headers
| Header | Description |
|---|---|
X-ResolveDB-Event | The event name, e.g. record.upserted. |
X-ResolveDB-Delivery | The unique delivery id. Stable across retries of the same delivery — use it to deduplicate. |
X-ResolveDB-Signature | The 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=="
}
}| Field | Description |
|---|---|
event | The event name (also in X-ResolveDB-Event). |
seq | A 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_at | ISO 8601 timestamp of when the change occurred. |
data | The 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:
- Read
tandv1from theX-ResolveDB-Signatureheader. - Derive the signing key:
signing_key = hex(SHA256(secret)). - Recompute the HMAC over
"<t>.<raw_body>"with that key. Use the raw request body bytes, before any JSON re-serialization. - Compare your result to
v1using a constant-time comparison. - Reject the delivery if they differ, or if
tis 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 "", 200Retries and Failure Handling
- Delivery is at-least-once: a non-
2xxresponse (or a connection/TLS error) is retried with exponential backoff, up to 5 attempts spanning roughly 25 minutes. Make your handler idempotent and dedupe onX-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
2xxand 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-
httpsURLs 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
- REST API Reference - Full endpoint reference
- Security - Authentication and encryption
- Quickstart - Get up and running in 5 minutes