Webhook events and delivery
Register HTTPS endpoints to receive signed webhook events as connections progress, with the event catalog, the payload envelope and how delivery retries work.
Register HTTPS endpoints to receive signed events as connections progress, as monitored domains drift, as nameserver moves go through and as the API keys a secure sign-in made near their end.
Register an endpoint
curl -X POST https://api.customdomain.ai/v1/webhooks \
-H "Authorization: Bearer <API_KEY>" \
-H "Content-Type: application/json" \
-d '{"url":"https://api.acme.example/webhooks/cd","events":["connection.live","domain.record_missing"]}'
# → { "id": "whk_…", "url": "...", "events": [...], "secret": "whsec_…", "created_at": "..." }Register from your server with an API key (or an app-scoped widget token). A
widget token minted by POST /v1/tokens has connect scope and is rejected
with 403. The url must be a public https URL; localhost and private or
link-local addresses are refused.
The secret (whsec_…) is returned once. Store it to
verify signatures.
An empty events array subscribes to all events; "*" also matches all.
Manage endpoints: GET /v1/webhooks lists them (secrets omitted; tenant-wide
with an API key, app-scoped with a widget JWT; ?app_id= narrows to one
application) and DELETE /v1/webhooks/{id} removes one (204). The tenant-wide list
leaves out a test mode application's endpoints unless
?app_id= names it, so a test endpoint is never mistaken for a live one.
Event catalog
| Event | Fires when |
|---|---|
connection.created | A connection is created. |
connection.applied | Records were written through a rail (data.via = automatic for OAuth, async for one-click setup, unset for API-token apply). |
connection.live | Every desired record resolved; the connection is live. |
connection.failed | A propagating connection's records never appeared within 24h. |
connection.records.updated | A managed connection was re-applied (POST /v1/connections/{id}:reapply); its desired records were re-written and the connection re-verified. |
connection.reapply.failed | A managed re-apply push failed; if the stored grant was revoked, the connection is flagged for re-consent. |
connection.disconnected | A domain was removed. removal says what happened to its DNS records: choice, outcome (removed, partly_removed, kept, manual, in_use, not_needed, failed), via, and the records removed, restored, left_in_place and failed, plus any manual_steps. |
connection.records_outdated | A domain needs new DNS records: it was moved, with the records it was shown before and the ones it needs now, or a non-managed connection's applied records were computed against a superseded edge target, with a connect_link to re-run setup. |
certificate.issued | The edge issued the first certificate for a domain. |
certificate.renewed | The edge renewed a domain's certificate. |
certificate.failed | Issuing or renewing failed (once per failure episode); the edge keeps retrying. |
certificate.expiring | A certificate that has not renewed expires in 14, 7 or 3 days, or expired. The certificate events carry a certificate object (status, issuer, issued_at, expires_at, days_left, error). See Certificates. |
credential.created | A secure sign-in made a lasting API key at the domain's DNS provider, and CustomDomain™ checked and kept it (status: "active"), or a reconnect put a new one in its place (status: "renewed"). Every credential.* event carries a credential object (see below), never the key, and goes to the key's own application only. |
credential.expiring | The key ends in 30 or 7 days (days_left). It keeps working until then; reconnect: true asks you to have your customer sign in again. |
credential.failed | The key stopped working: the provider refused it (reason: "refused") or it reached its end (expired), and CustomDomain™'s copy was deleted. Also when a key that was made could not be kept (not_kept; never for a stored key a sign-in used again, since no key was made) and when a reconnect could not make a new one (reconnect_failed). The domain stays connected; repairs wait for a reconnect. |
credential.removed | CustomDomain™'s copy of the key was deleted (the account was removed, or its domain was). The key itself lives at the provider until it is revoked at revoke_url. |
nameservers.started | A nameserver move was created. Every nameservers.* event carries a nameservers object (see below) and goes to the move's own application only. |
nameservers.dnssec_cleared | The registry stopped publishing the domain's DS record, so the nameservers can move. |
nameservers.changed | The registrar took the new nameservers, or the delegation first showed one of them. |
nameservers.active | The registry lists every new nameserver: the move is done. |
nameservers.failed | The move stopped. failure_code says why. |
nameservers.rolled_back | The domain was moved back, to its previous nameservers or to its registrar's own DNS (rollback_target). |
nameservers.drifted | An active move's nameservers were changed away. observed_nameservers says to what. |
domain.record_missing | Monitoring confirmed a record disappeared or was repointed. drift carries record_type, verdict (record_missing or record_changed), expected, observed and action (auto_fixed, fix_failed or action_required). |
domain.record_restored | A previously missing record is observed again. |
domain.purchased | A domain purchase completed: the domain is registered (and only then is the card captured). |
purchase.error | A purchase failed; the card authorization was released. |
purchase.confirmation.expired | A pending purchase's checkout session timed out unpaid; the buyer never confirmed. Release the cart. |
secure_status | A certificate's provisioning status changed (secure_status on the envelope). Sent only where the deployment has Secure on (SECURE_ENABLED). |
power_status | A reverse-proxy host's provisioning status changed (power_status on the envelope). |
When the connection carries a recorded diagnosis at emit time, the envelope also includes error_code and error_message (for example propagation_timeout, access_denied, ProviderAuthenticationError, or setup_incomplete on a stalled pending connection), so a receiver need not fetch the connection to learn why.
That table is the complete vocabulary: it is exactly the set the platform can
emit, and the events enum in the OpenAPI spec matches it.
An unknown event string is still accepted at registration, but it can never be
delivered, so subscribe only to the events above.
Which events the hosted service sends
The drift events (domain.record_missing and domain.record_restored) are
produced by the hourly Monitor sweep, which delivers them only where
MONITOR_ALERTS_ENABLED=1. With the flag off (the hosted default) the sweep
still runs in shadow mode but no drift webhooks are sent. domain.purchased
(and purchase.error) require the Sell purchase path, which is live on the
hosted service (gated by SELL_PURCHASE_ENABLED).
Payload envelope
Deliveries are a flat JSON object:
{
"id": "evt_…",
"type": "connection.live",
"user_id": "tenant-or-end-user-ref",
"domain": "app.customer.com",
"subdomain": "app",
"provider": "cloudflare",
"setup_type": "automatic",
"propagation_status": "success",
"data": {
"records_propagated": [ { "type": "CNAME", "host": "app.customer.com", "value": "edge.customdomain.ai" } ],
"records_non_propagated": []
},
"created_at": "2026-07-07T12:00:00Z"
}propagation_status is pending, success, or timeout. On propagation
events, data carries the per-record propagated / non-propagated arrays. Fields
that don't apply to an event are omitted.
Additional conditional fields ride connection events:
-
user_id: the connection'send_user_ref, echoed so integrators can associate lifecycle events with the originating tenant or end user. Omitted when the connection was created without anend_user_ref. A connection made from the console's Try it live carriesconsole:<member id>here, a prefix the console reserves for its own tests and never one of your customers, so a receiver can skip it. -
updated_objects: the records a write actually created or changed. Present onconnection.appliedandconnection.records.updatedonly; the events that just observe a connection never claim to have written anything. -
redirection_status:pending,successorfailedfor the apex↔www redirect, and present only on connections created withwww_redirect: truewhose domain is on the CustomDomain™ edge. A domain on your own infrastructure omits it: the edge serves no redirect there, so the redirect is yours to serve. -
connection_id: the connection the event is about, on connection, certificate and drift events. -
nameservers: on everynameservers.*event, the move it is about:migration_id,zone(the registrable domain),state,path(api,agentormanual),target(managed,dns_hostorcustom),old_nameserversandnew_nameservers, and where they applyobserved_nameservers(what the registry lists, onnameservers.activeandnameservers.drifted),failure_code(onnameservers.failed, anddelegation_driftonnameservers.drifted),records_copiedandemail_records(how many of the copied records carry mail). When they are known it also carriesregistrar, the company the domain is registered with, where the nameservers are changed (name, ourproviderid when we connect to it, andiana_id, its IANA Registrar ID), andold_dns_host, the DNS host the domain left (a provider id such ascloudflare). Onnameservers.rolled_back,rollback_targetsays where the move back went:previous(the nameservers the domain had) orregistrar_default(the registrar's own DNS). Each event is also written to the domain's timeline as a row of the same kind."nameservers": { "migration_id": "nsm_…", "zone": "acme.com", "state": "active", "path": "api", "target": "managed", "registrar": { "name": "GoDaddy", "provider": "godaddy", "iana_id": 146 }, "old_dns_host": "cloudflare", "old_nameservers": ["ada.ns.cloudflare.com", "bob.ns.cloudflare.com"], "new_nameservers": ["ns-1402.awsdns-47.org", "ns-905.awsdns-49.net"], "observed_nameservers": ["ns-1402.awsdns-47.org", "ns-905.awsdns-49.net"], "records_copied": 14, "email_records": 5 }The shape is illustrative: the values are made up.
-
credential: on everycredential.*event, the lasting API key it is about, never the key itself:account_id(the provider account that holds it),provider,status(active,renewed,expiring,needs_reconnect,expiredorremoved),key_name,scopes,expires_at(absent for a key with no end date),days_left(oncredential.expiring),reason(refused,expired,expiring,not_keptorreconnect_failed),revoke_url(the provider's page where the key is revoked),rights(what it can do beyond the domain's DNS:transfer,billingoraccount; see rights) andreconnect: truewhen your customer should sign in again, withPOST /v1/connections/{id}:reconnect-linkor the widget'smode: "reconnect".connection_idnames the domain's connection."credential": { "account_id": "pacct_…", "provider": "hostinger", "status": "expiring", "key_name": "CustomDomain Acme Sites", "expires_at": "2026-11-19T12:00:00Z", "days_left": 30, "reason": "expiring", "revoke_url": "https://hpanel.hostinger.com/api", "rights": ["account"], "reconnect": true }The shape is illustrative: the values are made up.
scopesis left out for a provider whose keys have none, as here. -
sandbox:trueon an event from a test mode application, whose domain is a simulated.testname. Real events carry nosandboxfield. Test mode events are signed and delivered like any other, so you can test your receiver end to end, and a receiver that writes to a real customer's account should check the field and skip them.
Delivery & retries
- A delivery succeeds on any
2xx. Respond within 15 seconds. - Network errors,
5xxand429are retried with exponential backoff (about 30 seconds, then doubling, capped at 6 hours between tries), up to 12 attempts in total. Any other4xxis treated as a permanent failure and is not retried. - Inspect outcomes:
GET /v1/webhook-deliveriesreturns the 100 most recent settled deliveries (attempts,status_code,delivered,error; filter with?app_id=and?type=). The log keeps the last 50 per application. A delivery that is still being retried appears once it succeeds or is given up on.
Endpoints should be idempotent: the same event can arrive more than once on
retry. Deduplicate on the envelope id, or on domain + type.
Next: verify signatures.
Widget tokens
Mint short-lived widget JWTs with POST /v1/tokens, choose between the connect and app scopes, and keep a long browser session alive without exposing an API key.
Verifying signatures
Verify webhook deliveries with the X-JE-Signature HMAC-SHA256 header and the timestamp, and reject replayed requests using a recommended five minute window.