Webhooks
CaptainDNS webhooks push your profile's events to the HTTP URL you configure. No more polling: every alert and every policy change reaches your endpoint within seconds, with a signed payload you can verify on the receiving side.
Overview
CaptainDNS sends a JSON POST to the configured URL whenever an event happens on your profile (monitor down, MTA-STS deployment failure, and so on). If you set a secret when creating the channel, the request is signed with HMAC-SHA256 and timestamped so you can reject replays. Each channel subscribes to one or more categories (monitoring, deployment, dns): only events belonging to the categories you checked are pushed to your endpoint.
On failure, the delivery is retried automatically up to 6 times with a backoff of 10s, 1min, 10min, 1h, 6h, 24h. The history of each delivery is available in the dashboard (Deliveries tab) and permanently failed deliveries can be replayed manually.
Setup
Webhook channels are created and managed from the CaptainDNS dashboard, under Notifications. Three fields are required:
- URL: publicly reachable HTTPS endpoint. HTTP URLs are rejected.
- Secret (optional but recommended): shared string between CaptainDNS and your receiver. Used to sign every request. Without a secret, no signature header is sent.
- Categories: at least one of
monitoring,deployment,dns. Filtering is enforced server-side at dispatch time.
The number of active channels per profile (webhooks and Slack integrations combined) depends on your plan:
| Plan | Active channels |
|---|---|
| Free | 1 |
| Starter | 3 |
| Pro | 10 |
| Business | 25 |
| Enterprise | 100 |
Above the quota, creation returns 402 webhook_quota_exceeded. To raise this limit, upgrade your plan or contact support.
CRUD operations run through dedicated REST routes (/v1/notifications/channels/*) protected by the Auth0 session: they are consumed by the dashboard and are not exposed to the public API key.
Request format
Every event produces a request in the following shape:
POST https://your-endpoint.captaindns.com/ HTTP/1.1
Content-Type: application/json
User-Agent: CaptainDNS-Webhook/2.0 (+https://www.captaindns.com/en/docs/api/webhooks)
X-CaptainDNS-Event-ID: 7a3c9b1e-2f4d-4a6b-8c7d-9e1f2a3b4c5d
X-CaptainDNS-Delivery-ID: b8c1f4e2-5a6b-4c7d-8e9f-0a1b2c3d4e5f
X-CaptainDNS-Attempt: 1/6
X-CaptainDNS-Event-Type: MONITOR_DOWN
X-CaptainDNS-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
X-CaptainDNS-Timestamp: 1776550245
{"schema_version":"2","event_type":"MONITOR_DOWN","category":"monitoring", ...}
Details:
- Method:
POSTon the exact configured URL. Content-Type: alwaysapplication/json.User-Agent:CaptainDNS-Webhook/2.0 (+https://www.captaindns.com/en/docs/api/webhooks). Useful to filter traffic in your logs.X-CaptainDNS-Event-ID: stable identifier of the business event. Preserved across all attempts and manual replays. Recommended key to deduplicate on the receiver side.X-CaptainDNS-Delivery-ID: unique identifier for the attempt. Changes on every retry and every replay.X-CaptainDNS-Attempt: counter in the formn/6(maximum number of attempts).X-CaptainDNS-Event-Type: copy of theevent_typefield from the body, handy for routing without parsing the JSON.X-CaptainDNS-Signature: present when a secret is configured. Formatsha256=<hex>.X-CaptainDNS-Timestamp: present when a secret is configured. Unix timestamp in seconds, used in the signature computation and to guard against replays.- Dispatch timeout: CaptainDNS waits at most 10 seconds for a response from your endpoint. Beyond that, the attempt is considered a failure.
- Retries: 6 attempts in total (1 initial plus 5 retries), backoff
10s, 1min, 10min, 1h, 6h, 24hon statuses5xx,408,429and network errors (timeout, DNS, TLS). Other4xxresponses (400, 401, 403, 404, 422, and so on) move immediately tofailed_permanentwithout retry. After 20 consecutivefailed_permanentdeliveries, the channel is auto-disabled and an email is sent to the owner.
Payload format
The JSON body follows the V2 schema (schema_version: "2"):
{
"schema_version": "2",
"event_id": "7a3c9b1e-2f4d-4a6b-8c7d-9e1f2a3b4c5d",
"delivery_id": "b8c1f4e2-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
"attempt": 1,
"event_type": "MONITOR_DOWN",
"category": "monitoring",
"timestamp": "2026-04-14T10:30:45Z",
"subject": "[CaptainDNS] Monitor DOWN",
"status": "sent",
"data": {
"monitor_id": "mon_3f8a1c",
"target": "captaindns.com",
"failure_reason": "connection timeout"
}
}
schema_version: payload schema version. Always"2"for current deliveries.event_id: stable UUID across all attempts and replays of the same event. Recommended deduplication key.delivery_id: unique UUID per attempt. Useful to correlate with the Deliveries dashboard.attempt: attempt number (1 to 6).event_type: stable event identifier, in SCREAMING_SNAKE_CASE. See the list below.category: webhook category amongmonitoring,deployment,dns.timestamp: emission date in RFC 3339 UTC format.subject: human-readable label, reused as the email subject for the equivalent mail channel.status: dispatch state on the CaptainDNS side, typically"sent".data: free-form object whose shape depends on theevent_type. May be omitted or empty depending on the event.
Treat the payload as tolerant: new fields may appear without a version bump. Your parser must ignore them silently.
Signature verification
When a secret is configured, CaptainDNS signs the request with HMAC-SHA256 so the receiver can verify the origin and integrity of the payload.
Algorithm:
- Read the timestamp from the
X-CaptainDNS-Timestampheader and the raw request body (before any JSON parsing). - Concatenate
<timestamp>.<raw_body>. - Compute the HMAC-SHA256 with your secret.
- Compare the hex result against the
X-CaptainDNS-Signatureheader (after stripping thesha256=prefix), in constant time.
Reject any request whose timestamp drifts more than 5 minutes from the current time, to limit replay attacks.
Node.js
import crypto from "node:crypto";
function verifyWebhook(rawBody, signatureHeader, timestamp, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex");
const received = signatureHeader.replace(/^sha256=/, "");
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(received, "hex"),
);
}
Important: rawBody must be the raw buffer as received, before JSON.parse. With Express, use express.raw({ type: "application/json" }) on the webhook route. With Next.js, read the stream via await request.text().
Python
import hmac, hashlib
def verify_webhook(raw_body: bytes, signature_header: str, timestamp: str, secret: str) -> bool:
mac = hmac.new(secret.encode(), digestmod=hashlib.sha256)
mac.update(f"{timestamp}.".encode())
mac.update(raw_body)
expected = mac.hexdigest()
received = signature_header.removeprefix("sha256=")
return hmac.compare_digest(expected, received)
With FastAPI, get the raw body via await request.body(). With Flask, use request.get_data() without accessing request.json first.
Event list
The 22 events currently emitted, grouped by webhook category (monitoring, deployment, dns). Each event belongs to a single category, used for server-side filtering based on the categories checked on the channel.
monitoring category (7)
Uptime, TLS health, broken redirects, cross-cutting alerts.
event_type | Description |
|---|---|
MONITOR_DOWN | A monitor flips to DOWN after several consecutive failures. |
MONITOR_RECOVERY | A monitor returns to UP after a DOWN. |
MONITOR_DISABLE_WARNING | A permanently failing monitor is nearing automatic disablement. |
MONITOR_AUTO_DISABLED | A monitor was automatically disabled after too many prolonged failures. |
TLS_EXPIRY_WARNING | An observed TLS certificate is nearing its expiration date. |
REDIRECT_DOWN | A hosted redirect is no longer responding correctly. |
GENERIC_ALERT | Cross-cutting alert that does not fit any dedicated category. |
deployment category (13)
Activation, failures, and deactivation of hosted mail policies (MTA-STS, TLS-RPT, DMARC, BIMI), redirects, and domain verification.
event_type | Description |
|---|---|
MTASTS_ACTIVATED | A hosted MTA-STS policy switched to enforce mode. |
MTASTS_DEPLOY_FAILURE | An MTA-STS deployment failed (DNS, certificate, or policy). |
MTASTS_DEACTIVATED | A hosted MTA-STS policy was deactivated. |
TLSRPT_ACTIVATED | A hosted TLS-RPT record became active. |
TLSRPT_DEPLOY_FAILURE | A TLS-RPT deployment failed. |
TLSRPT_HIGH_FAILURE | An aggregate TLS-RPT report signals a high failure rate. |
DMARC_ACTIVATED | A hosted DMARC policy moved to quarantine or reject. |
DMARC_DEPLOY_FAILURE | A DMARC deployment failed. |
DMARC_ALIGNMENT_DROP | The DMARC alignment rate drops below a critical threshold. |
BIMI_CERT_EXPIRY | A hosted VMC or CMC certificate is nearing expiration. |
REDIRECT_ACTIVATED | A hosted redirect became active. |
REDIRECT_DEACTIVATED | A hosted redirect was deactivated. |
DOMAIN_REVERIFY_FAILED | A periodic domain ownership re-verification failed. |
dns category (2)
DNS diffs and latency anomalies detected by resolve watches.
event_type | Description |
|---|---|
RESOLVE_WATCH_DIFF | A resolve watch detects a change in the DNS response. |
RESOLVE_LATENCY_ANOMALY | A resolve watch detects a significant latency anomaly. |
Delivery dashboard
Every attempt is persisted in the webhook_deliveries table and browsable in the dashboard, under the Deliveries sub-tab of the Notifications section. Available columns: timestamp, channel, event_type, returned HTTP status, attempts counter out of 6, status (pending, retrying, sent, failed_permanent).
Deliveries in failed_permanent state (6 attempts exhausted) can be replayed manually from the table. A replay inserts a new row with the same event_id but a new delivery_id and resets the counter to 0: up to 6 fresh attempts are scheduled.
History is kept for 90 days across all plans, then purged by a daily worker. A future release will align retention with the plan's log retention window (log_retention_days).
A Send test button on each active webhook channel sends a dummy payload with event_type: "TEST" in the monitoring category. Rate-limited to 5 tests per minute per profile.
Error codes
Errors returned by the CRUD endpoints use the following codes:
| Status | Code | Meaning |
|---|---|---|
400 | invalid_categories | The submitted category list is empty or contains unsupported values. |
402 | webhook_quota_exceeded | The profile has reached its plan limit of active channels. |
409 | label_conflict | Another channel already uses this label on the profile. |
422 | channel_not_webhook | The target channel is not a webhook (cannot send a test). |
422 | channel_disabled | The channel is disabled (cannot send a test). |
429 | test_rate_limited | More than 5 tests per minute per profile. |
Receiver best practices
- Respond quickly: return a
2xxstatus in under 5 seconds. If processing is long, acknowledge immediately and hand off to an async queue. - Verify the signature before trusting anything: never deserialize the payload before validating
X-CaptainDNS-Signature, except to read the timestamp. - Tolerate unknown fields: your parser must silently ignore unknown keys, both at the root level and inside
data. This ensures forward compatibility. - Treat
event_typeas an open enum: ignore types you don't know how to handle instead of failing. - Application-level idempotency: the same notification can arrive more than once (network retry, manual replay from the dashboard). Use
event_idas your deduplication key: it stays stable across all attempts and all replays of the same event.delivery_idchanges on every attempt. - Log
event_idanddelivery_id: useful to correlate with the Deliveries sub-tab of the dashboard during an incident.
Current limits and roadmap
- Filtering at the category level, not at
event_type: channels subscribe tomonitoring,deployment, ordns. If you only want to process a specific subset (for example onlyMONITOR_DOWN), filter on the receiver side based on theevent_typefield. - Dashboard-only CRUD endpoints: the
/v1/notifications/channels/*and/v1/notifications/deliveries/*routes are protected by the Auth0 session and are not accessible with a public API key (cdns_live_*/cdns_test_*). Programmatic channel management via API key is on the roadmap. - Fixed 90-day retention: alignment with
log_retention_days(plan-based) is planned for a later iteration. - HMAC-SHA256 signatures only: Ed25519 support and secret rotation with a grace period are under consideration for a future version.
Updates will be announced in the public API changelog.