# 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.

Source: https://docs.customdomain.ai/docs/webhooks/overview

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

```bash
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": "..." }
```

```js
const res = await fetch("https://api.customdomain.ai/v1/webhooks", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CUSTOMDOMAIN_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://api.acme.example/webhooks/cd",
    events: ["connection.live", "domain.record_missing"],
  }),
});
const { id, secret } = await res.json(); // store secret (whsec_…) now: it is not shown again
```

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](/docs/webhooks/verifying-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](/docs/guides/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](/docs/connect-flow/managed-connections) 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](/docs/connect-flow/offboarding). `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](/docs/connect-flow/end-user-notices), 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](/docs/connect-flow/certificates).                                                                                                                                                                                        |
| `credential.created`            | A [secure sign-in](/docs/guides/secure-sign-in#lasting-api-keys) made a lasting API key at the domain's DNS provider, and CustomDomain™ checked and kept it (`status: "active"`), or a [reconnect](/docs/guides/secure-sign-in#reconnect) put a new one in its place (`status: "renewed"`). Every `credential.*` event carries a `credential` object (see [below](#payload-envelope)), 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](/docs/guides/nameserver-migration) was created. Every `nameservers.*` event carries a `nameservers` object (see [below](#payload-envelope)) 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](/docs/connect-flow/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](/docs/sell/buying-a-domain) 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](/v1/openapi.json) 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:

```json
{
  "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](/docs/connect-flow/routing#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.

  ```json
  "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](/docs/connect-flow/provider-accounts#keys-made-at-a-secure-sign-in)
  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](/docs/guides/secure-sign-in#every-domain-scope-said-in-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.

  ```json
  "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](/docs/guides/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](/docs/webhooks/verifying-signatures).
