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:
| 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.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:
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 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)
secretandsigning_secretare separate values. Usesigning_secretdirectly for HMAC verification; do not hash or otherwise derive a key fromsecret.
To verify a delivery:
- Read
tandv1from theX-ResolveDB-Signatureheader. - Recompute the HMAC over
"<t>.<raw_body>"withsigning_secret. 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 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 "", 200Retries and Failure Handling
- Once an eligible delivery is enqueued, a non-
2xxresponse or connection/TLS error is retried with exponential backoff, up to 5 attempts. Make your handler idempotent and dedupe onX-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
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
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
- REST API Reference - Full endpoint reference
- Security - Authentication and encryption
- Quickstart - Get up and running in 5 minutes