CustomDomain Docs

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

EventFires when
connection.createdA connection is created.
connection.appliedRecords were written through a rail (data.via = automatic for OAuth, async for one-click setup, unset for API-token apply).
connection.liveEvery desired record resolved; the connection is live.
connection.failedA propagating connection's records never appeared within 24h.
connection.records.updatedA managed connection was re-applied (POST /v1/connections/{id}:reapply); its desired records were re-written and the connection re-verified.
connection.reapply.failedA managed re-apply push failed; if the stored grant was revoked, the connection is flagged for re-consent.
connection.disconnectedA 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_outdatedA 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.issuedThe edge issued the first certificate for a domain.
certificate.renewedThe edge renewed a domain's certificate.
certificate.failedIssuing or renewing failed (once per failure episode); the edge keeps retrying.
certificate.expiringA 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.createdA 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.expiringThe 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.failedThe 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.removedCustomDomain™'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.startedA nameserver move was created. Every nameservers.* event carries a nameservers object (see below) and goes to the move's own application only.
nameservers.dnssec_clearedThe registry stopped publishing the domain's DS record, so the nameservers can move.
nameservers.changedThe registrar took the new nameservers, or the delegation first showed one of them.
nameservers.activeThe registry lists every new nameserver: the move is done.
nameservers.failedThe move stopped. failure_code says why.
nameservers.rolled_backThe domain was moved back, to its previous nameservers or to its registrar's own DNS (rollback_target).
nameservers.driftedAn active move's nameservers were changed away. observed_nameservers says to what.
domain.record_missingMonitoring 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_restoredA previously missing record is observed again.
domain.purchasedA domain purchase completed: the domain is registered (and only then is the card captured).
purchase.errorA purchase failed; the card authorization was released.
purchase.confirmation.expiredA pending purchase's checkout session timed out unpaid; the buyer never confirmed. Release the cart.
secure_statusA certificate's provisioning status changed (secure_status on the envelope). Sent only where the deployment has Secure on (SECURE_ENABLED).
power_statusA 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's end_user_ref, echoed so integrators can associate lifecycle events with the originating tenant or end user. Omitted when the connection was created without an end_user_ref. A connection made from the console's Try it live carries console:<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 on connection.applied and connection.records.updated only; the events that just observe a connection never claim to have written anything.

  • redirection_status: pending, success or failed for the apex↔www redirect, and present only on connections created with www_redirect: true whose 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 every nameservers.* event, the move it is about: migration_id, zone (the registrable domain), state, path (api, agent or manual), target (managed, dns_host or custom), old_nameservers and new_nameservers, and where they apply observed_nameservers (what the registry lists, on nameservers.active and nameservers.drifted), failure_code (on nameservers.failed, and delegation_drift on nameservers.drifted), records_copied and email_records (how many of the copied records carry mail). When they are known it also carries registrar, the company the domain is registered with, where the nameservers are changed (name, our provider id when we connect to it, and iana_id, its IANA Registrar ID), and old_dns_host, the DNS host the domain left (a provider id such as cloudflare). On nameservers.rolled_back, rollback_target says where the move back went: previous (the nameservers the domain had) or registrar_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 every credential.* 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, expired or removed), key_name, scopes, expires_at (absent for a key with no end date), days_left (on credential.expiring), reason (refused, expired, expiring, not_kept or reconnect_failed), revoke_url (the provider's page where the key is revoked), rights (what it can do beyond the domain's DNS: transfer, billing or account; see rights) and reconnect: true when your customer should sign in again, with POST /v1/connections/{id}:reconnect-link or the widget's mode: "reconnect". connection_id names 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. scopes is left out for a provider whose keys have none, as here.

  • sandbox: true on an event from a test mode application, whose domain is a simulated .test name. Real events carry no sandbox field. 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, 5xx and 429 are retried with exponential backoff (about 30 seconds, then doubling, capped at 6 hours between tries), up to 12 attempts in total. Any other 4xx is treated as a permanent failure and is not retried.
  • Inspect outcomes: GET /v1/webhook-deliveries returns 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.

On this page