# Detect where a domain's DNS lives and how it will be set up

> Detect where a domain's DNS lives and how it will be set up: CustomDomain™ API reference in the domains group. Covers parameters, request body and responses.

Source: https://docs.customdomain.ai/docs/api-reference/domains/checkDomain

## `POST https://api.customdomain.ai/v1/domains:check`

Read-only feature detection. Returns the provider, the chosen `setup_type`, whether automatic setup is possible, whether the OAuth-into-provider and one-click setup rails are available, capability flags (`ns_support`, `wildcard_support`, `cname_flattening`, `spf_override_support`, `caa_support`), social-login support, any `record_conflicts` observed in public DNS (records the automatic write would replace), and any `advisories` (warnings that replace nothing, such as a CAA record that blocks Let's Encrypt). Unknown providers degrade to `setup_type: manual`.

Apex pre-flight: `apex_supported` reports whether the provider can host a CNAME-like record at the zone root, and `apex_message` is returned when this particular target needs one the provider cannot host, so an apex connection that would be refused at apply time is refused here instead, before any connection is created.

The answer is for one application (where its domains point: `target_mode` and the apex strategy). A widget JWT checks for its own application. An API key checks for `application_id`, which must be an application in the key's tenant (404 otherwise), or for the key's own application when it is omitted.

`rails.cloud_browser` (the secure sign-in) is offered only to a caller that could start it, with the reason the start would refuse it with: a widget token bound to no domain, or only to other domains, reads `available: false` with `token_not_bound` (mint one with `domain` and `end_user_ref`), and an agent, a plain API call without the console or a role that cannot write connections reads `caller_cannot_start`.

A delegated agent token needs `domains:read` or `domains:connect` and checks for its own application; naming another application is a `403`.

`rails.saved_access` lists the kept keys that may act for the domain for the caller, and `recommended_rail` is then `saved_access`. An API key gets the workspace's keys. A widget token bound to the domain that names the end user (`end_user_ref`) gets that end user's own keys, and only when the application allows it (`end_user_saved_access`), the e-mail that tells them of the reuse can be sent and the key was made at a sign in started with a token minted with their `end_user_ref`, never at GoDaddy for now (see `GET /connections/{id}/saved-access`); a miss, for any reason, leaves the other rails as they were, so the end user signs in. The offer is read only: an end user's token cannot use the key yet.

Where the secure sign-in also makes a lasting API key, `rails.cloud_browser.key` names it before the person signs in: `name` is the name a key made now would get, before the four character code the sign-in adds at its end (a sign-in makes or reuses a key, so look for a key named like it), `delete_url` is the provider's own page that deletes keys, and `rights` and `ends_in_days` say what it can do and when it ends.

`rails.nameservers` is the DNS hosting verdict, present while the application offers DNS hosting. `verdict` and `reason` say where it goes and why. It is `recommended` where hosting saves the person the most: four or more records to write (`many_records`), or no automatic way (`no_automatic_way`). A root domain whose provider has no record that can point a root is not a reason of its own: a zone CustomDomain™ hosts cannot point a root at a platform that gives no apex addresses either, so hosting is only offered there. On CustomDomain™'s own DNS it falls back to `offered` with `plan_zone_limit` once the workspace holds as many hosted zones as its plan counts; nothing is refused.

### Authentication

`widgetJWT`, `apiKey`, `agentToken`, `agentToken`

### Request body (JSON)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `domain` | string | yes |  |
| `application_id` | string | no | With an API key, the application to check for (defaults to the key's own). A widget JWT may only omit it or send its own application id (403 otherwise). |

### Responses

| Status | Description |
| --- | --- |
| `200` | Detection result |
| `400` | Error envelope: {code, title, details}. |
| `401` | Error envelope: {code, title, details}. |
| `403` | Error envelope: {code, title, details}. |
| `404` | Error envelope: {code, title, details}. |

### `200` response body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `domain` | string | yes |  |
| `sandbox` | boolean | no | `true` when the check ran for a test mode application: nothing was looked up, the answer is always the manual rail, and the domain must be a reserved `.test` name. Omitted on a real check. |
| `subdomain` | string | no | Server's authoritative Public-Suffix-List parse verdict: the subdomain labels of `domain`, empty string when the domain IS the registrable apex. Clients must adopt this boundary instead of computing their own. |
| `registrable_domain` | string | no | The registrable domain (eTLD+1) of `domain` per the full PSL. |
| `public_suffix` | string | no | The effective TLD (public suffix) of `domain`, e.g. `com`, `co.uk`, `com.ng`. |
| `provider` | string or null | no |  |
| `setup_type` | `automatic`, `manual`, `async`, `mcp` | yes | How a connection reaches live. |
| `supports_automatic` | boolean | no |  |
| `oauth_available` | boolean | no | the OAuth-into-provider rail can be offered |
| `domain_connect` | boolean | no | the one-click (Domain Connect sync) rail is offered for this domain. Same predicate as `rails.domain_connect.available`: the provider answered discovery AND, when a provider allowlist is configured (DC_SYNC_PROVIDERS, the default), the provider is on it, i.e. is measured to serve our template catalog. Providers that speak the protocol but have not onboarded our template (GoDaddy, IONOS, Cloudflare) report false even with DC_SYNC_ENABLED on. A domain reported true will not have its domainconnect:start refused for lack of the rail. |
| `registered` | boolean | no | Whether the domain resolves to a delegated zone. OMITTED (not false) when detection failed for a transient reason; only an NXDOMAIN answer reports `false`. Do not tell a user their domain is unregistered unless this field is present and false. |
| `detection_error` | `nxdomain`, `resolver` | no | Present only on a degraded check (detection failed): `nxdomain` means the domain is not delegated (`registered` is false, `blocked_reason` is `unregistered`); `resolver` means a transient resolver failure; the provider is unknown for now, retry the check. |
| `authoritative_dns_provider` | string or null | no |  |
| `ns_support` | object | no |  |
| `wildcard_support` | boolean | no |  |
| `cname_flattening` | boolean | no |  |
| `apex_supported` | boolean | no | The provider can serve CNAME-like data at the zone root (ALIAS/ANAME/flattened CNAME). Always agrees with `cname_flattening`. Reported for every check, apex target or not. |
| `apex_message` | string | no | Present only when the record set this check ran against needs an apex record the provider cannot host, i.e. exactly when the apply would refuse with `apex_not_supported`. Gate any warning UI on this field, not on `apex_supported`: a subdomain connect on a provider with no apex capability reports `apex_supported: false` and no message, because nothing in that flow breaks. |
| `target_mode` | `edge`, `custom` | no | Where this application's domains point: `edge` (proxied by the platform) or `custom` (the application's own connection target, see `PUT /applications/{id}/connect-target`). |
| `apex_strategy` | `alias`, `a_records`, `unsupported` | no | How a ROOT domain is pointed for this application on the detected provider: `alias` (an ALIAS, ANAME or flattened CNAME record to a hostname), `a_records` (address records to `apex_targets`), or `unsupported` (neither is possible; `apex_message` explains). |
| `apex_record_type` | string | no | The record type to choose in the provider's panel for the root (ALIAS, ANAME, CNAME or A). |
| `apex_targets` | array of string | no | The addresses a root domain's A records point at when `apex_strategy` is `a_records`. |
| `spf_override_support` | boolean | no |  |
| `caa_support` | boolean | no |  |
| `supports_social_login` | string | no | yes \| no \| n-a |
| `rails` | object | no | Rail truth: per rail, whether it can run for THIS domain right now, and when it can't, one machine reason why. `setup_type: automatic` / `supports_automatic` only ever meant "an adapter exists"; this block is what to show a user. Present on every check; a degraded check (detection failed) reports only `manual` available. |
| `also_provider` | object | no | The second way, when the registrar and the nameservers name two providers: a former Google Domains name (now Squarespace's) keeps Google's `ns-cloud-*.googledomains.com` nameservers, which Google Cloud DNS zones use too. `provider` is then the registrar's (`squarespace`, found over RDAP, so its secure sign-in is offered) and this is the nameservers' (`gcpdns`). Absent otherwise, and when the registrar lookup answers nothing. |
| `recommended_rail` | `saved_access`, `oauth`, `domain_connect`, `cloud_browser`, `api_key`, `manual` | no | The rail the server recommends: `saved_access` when the caller already keeps a key that may act for the domain, else by precedence oauth → domain_connect → cloud_browser → api_key → manual (least typing for the end user first). `cloud_browser` is recommended only when it is available and the API key rail is unavailable, carries a caveat, or the sign in makes the API key itself (`rails.cloud_browser.key`). Absent when `blocked_reason` is set. The console and the widget follow this field so the surfaces never disagree. |
| `end_user_automatic` | boolean | no | True when the recommended rail needs nothing typed by the end user (OAuth or one-click). This is the fact `supports_automatic` was always misread as. An API-key rail is "automatic" only for someone holding an API key. |
| `blocked_reason` | `unregistered`, `platform_subdomain` | no | Present when no rail can connect this domain. `unregistered`: no nameserver delegation at all (fix it at the registrar). `platform_subdomain`: a hosting platform's own subdomain (www.<name>.vercel.app). Its DNS is the platform's; `POST /v1/connections` refuses it with `platform_subdomain`. |
| `dashboard_url` | string | no | Best-effort deep link into the detected provider's DNS panel for this zone, to send the user to when they add records by hand. Absent when unknown. |
| `nameservers` | array of string | no | The nameserver delegation the detector observed (normalized hostnames). |
| `managed_dns` | object | no | Whether CustomDomain™ can host this domain's zone (managed DNS). Always present on the domains:check response. It is the deployment's and the domain's verdict only: whether the end user is offered DNS hosting is the application's `nameserver_migration` setting, and a widget token can start managed DNS only when that setting is on with target `managed`. |
| `nameserver_migration` | object | no | What the widget offers for this domain, present only when the calling application's `nameserver_migration.mode` is `offer` or `prefer`. |
| `integration_warnings` | array of object | no | Problems with how YOU configured the integration, not with the end user's domain. They are returned only when you supplied your own `records`. The widget never renders these; they are for your logs and your developers. Present because this class of failure is invisible to the party who can fix it: an integrator testing with `www.` sees everything work and ships, and only their end users hit the wall. |
| `record_conflicts` | array of object | no | Existing DNS records the automatic write would replace (`value-mismatch`, `cname-collision`, `spf-merge`); these count toward `will_fallback_to_manual`. Within /v1 a `caa-blocks-letsencrypt` advisory is also listed here, as before `advisories` existed; it never counts toward the fallback, and new clients should read it from `advisories`. |
| `advisories` | array of object | no | Warnings that are not a record the automatic write would replace, so they never count toward `will_fallback_to_manual`. Today one kind: `caa-blocks-letsencrypt`, a CAA record that does not authorize Let's Encrypt, which blocks the certificate; the fix is to add a CAA record for `letsencrypt.org` next to the existing ones, never to remove them. Returned only when the application's domains route to the edge. |
| `conflict_tolerance` | integer | no | Provider's automated-write conflict threshold (0/absent = no modeled limit). |
| `will_fallback_to_manual` | boolean | no | True when current record conflicts exceed the provider's threshold, so an automated flow would silently drop to manual. Have the user clear conflicts first. |

The full specification is served at `GET https://api.customdomain.ai/v1/openapi.json`.
