---
title: "Detect where a domain's DNS lives and how it will be set up"
method: POST
path: "/domains:check"
tags: ["Domains"]
---

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

`POST /domains:check`

## Request

- Base URL: `https://api.customdomain.ai/v1`
- URL: `https://api.customdomain.ai/v1/domains:check`
- Auth: one of:
  - HTTP bearer
  - HTTP bearer
  - OAuth 2 (authorization code, authorization URL `https://api.customdomain.ai/oauth/agent/authorize`, token URL `https://api.customdomain.ai/oauth/agent/token`, scopes: `domains:read`)
  - OAuth 2 (authorization code, authorization URL `https://api.customdomain.ai/oauth/agent/authorize`, token URL `https://api.customdomain.ai/oauth/agent/token`, scopes: `domains:connect`)

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, and any `record_conflicts` observed in public DNS. 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.

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

## Request body

- object
  - `domain` string, required
  - `application_id` string: 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).

## Response `200`

Detection result

- DomainCheck
  - `domain` string, required
  - `sandbox` boolean: `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: 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: The registrable domain (eTLD+1) of `domain` per the full PSL.
  - `public_suffix` string: The effective TLD (public suffix) of `domain`, e.g. `com`, `co.uk`, `com.ng`.
  - `provider` string, nullable
  - `setup_type` 'automatic' | 'manual' | 'async' | 'mcp', required: How a connection reaches live.
  - `supports_automatic` boolean
  - `oauth_available` boolean: the OAuth-into-provider rail can be offered
  - `domain_connect` boolean: 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: 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': 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, nullable
  - `ns_support` object
    - `root` boolean
    - `subdomains` boolean
  - `wildcard_support` boolean
  - `cname_flattening` boolean
  - `apex_supported` boolean: 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: 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': 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': 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: The record type to choose in the provider's panel for the root (ALIAS, ANAME, CNAME or A).
  - `apex_targets` string[]: The addresses a root domain's A records point at when `apex_strategy` is `a_records`.
  - `spf_override_support` boolean
  - `caa_support` boolean
  - `supports_social_login` string: yes | no | n-a
  - `rails` object: 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.
    - `oauth` RailInfo
      - `available` boolean, required
      - `reason` 'unknown_provider' | 'provider_has_no_oauth' | 'oauth_client_not_registered' | 'not_discovered' | 'template_not_onboarded' | 'redirect_unavailable' | 'rail_disabled' | 'custom_routing' | 'no_adapter' | 'unregistered' | 'platform_subdomain' | 'provider_not_listed' | 'not_enabled_for_workspace' | 'oauth_available' | 'script_unhealthy' | 'vendor_unavailable' | 'monthly_cap' | 'sign_in_not_wired': Set exactly when `available` is false. `provider_has_no_oauth`: the provider publishes no OAuth rail. `oauth_client_not_registered`: it does, but this deployment has not set it up: no client registered, or an MCP server sign-in not listed in `MCP_SIGNIN_PROVIDERS` (operator task). `not_discovered`: no Domain Connect discovery answer. `template_not_onboarded`: the provider speaks Domain Connect but has not onboarded our template. `redirect_unavailable`: the provider requires our return link on a one-click apply and the template cannot carry it yet. `rail_disabled`: the sync rail is off in this deployment. `custom_routing`: the application routes domains to its own infrastructure (a connect target) and the one-click templates point domains at the CustomDomain™ edge. `no_adapter`: no write adapter for the API-key rail. `unregistered` / `platform_subdomain`: the domain is blocked (see `blocked_reason`). For `rails.cloud_browser` only: `provider_not_listed` (no script for this provider, or the deployment does not offer it), `not_enabled_for_workspace` (the workspace is not on the pilot list), `oauth_available` (the provider has a sign in of its own, which wins), `script_unhealthy` (its script failed in recent sessions and the rail is paused), `vendor_unavailable` (the browser vendor failed to create sessions and the rail is paused), `monthly_cap` (the monthly browser minutes are used) and `sign_in_not_wired` (the sign in token cannot be signed).
      - `caveat` string: A note on an AVAILABLE rail: what the end user must create or qualify for before it works (a GoDaddy Personal Access Token; Namecheap's API eligibility and IP allowlist). Show it; do not treat it as unavailability.
      - `flow` 'oauth2' | 'vercel_integration' | 'porkbun_handoff' | 'ovh_consent' | 'mcp_oauth': Available `oauth` rail only: the sign-in it runs, so a client can label its button (see `POST /connections/{id}/oauth:start`).
      - `managed` boolean: Available `oauth` rail only: the connect will keep its credential (managed mode). Omitted when it will not.
      - `regions` string[]: Available `ovh_consent` rail only: the OVHcloud API regions this deployment can run the consent in, the default first.
    - `domain_connect` RailInfo
      - `available` boolean, required
      - `reason` 'unknown_provider' | 'provider_has_no_oauth' | 'oauth_client_not_registered' | 'not_discovered' | 'template_not_onboarded' | 'redirect_unavailable' | 'rail_disabled' | 'custom_routing' | 'no_adapter' | 'unregistered' | 'platform_subdomain' | 'provider_not_listed' | 'not_enabled_for_workspace' | 'oauth_available' | 'script_unhealthy' | 'vendor_unavailable' | 'monthly_cap' | 'sign_in_not_wired': Set exactly when `available` is false. `provider_has_no_oauth`: the provider publishes no OAuth rail. `oauth_client_not_registered`: it does, but this deployment has not set it up: no client registered, or an MCP server sign-in not listed in `MCP_SIGNIN_PROVIDERS` (operator task). `not_discovered`: no Domain Connect discovery answer. `template_not_onboarded`: the provider speaks Domain Connect but has not onboarded our template. `redirect_unavailable`: the provider requires our return link on a one-click apply and the template cannot carry it yet. `rail_disabled`: the sync rail is off in this deployment. `custom_routing`: the application routes domains to its own infrastructure (a connect target) and the one-click templates point domains at the CustomDomain™ edge. `no_adapter`: no write adapter for the API-key rail. `unregistered` / `platform_subdomain`: the domain is blocked (see `blocked_reason`). For `rails.cloud_browser` only: `provider_not_listed` (no script for this provider, or the deployment does not offer it), `not_enabled_for_workspace` (the workspace is not on the pilot list), `oauth_available` (the provider has a sign in of its own, which wins), `script_unhealthy` (its script failed in recent sessions and the rail is paused), `vendor_unavailable` (the browser vendor failed to create sessions and the rail is paused), `monthly_cap` (the monthly browser minutes are used) and `sign_in_not_wired` (the sign in token cannot be signed).
      - `caveat` string: A note on an AVAILABLE rail: what the end user must create or qualify for before it works (a GoDaddy Personal Access Token; Namecheap's API eligibility and IP allowlist). Show it; do not treat it as unavailability.
      - `flow` 'oauth2' | 'vercel_integration' | 'porkbun_handoff' | 'ovh_consent' | 'mcp_oauth': Available `oauth` rail only: the sign-in it runs, so a client can label its button (see `POST /connections/{id}/oauth:start`).
      - `managed` boolean: Available `oauth` rail only: the connect will keep its credential (managed mode). Omitted when it will not.
      - `regions` string[]: Available `ovh_consent` rail only: the OVHcloud API regions this deployment can run the consent in, the default first.
    - `api_key` RailInfo
      - `available` boolean, required
      - `reason` 'unknown_provider' | 'provider_has_no_oauth' | 'oauth_client_not_registered' | 'not_discovered' | 'template_not_onboarded' | 'redirect_unavailable' | 'rail_disabled' | 'custom_routing' | 'no_adapter' | 'unregistered' | 'platform_subdomain' | 'provider_not_listed' | 'not_enabled_for_workspace' | 'oauth_available' | 'script_unhealthy' | 'vendor_unavailable' | 'monthly_cap' | 'sign_in_not_wired': Set exactly when `available` is false. `provider_has_no_oauth`: the provider publishes no OAuth rail. `oauth_client_not_registered`: it does, but this deployment has not set it up: no client registered, or an MCP server sign-in not listed in `MCP_SIGNIN_PROVIDERS` (operator task). `not_discovered`: no Domain Connect discovery answer. `template_not_onboarded`: the provider speaks Domain Connect but has not onboarded our template. `redirect_unavailable`: the provider requires our return link on a one-click apply and the template cannot carry it yet. `rail_disabled`: the sync rail is off in this deployment. `custom_routing`: the application routes domains to its own infrastructure (a connect target) and the one-click templates point domains at the CustomDomain™ edge. `no_adapter`: no write adapter for the API-key rail. `unregistered` / `platform_subdomain`: the domain is blocked (see `blocked_reason`). For `rails.cloud_browser` only: `provider_not_listed` (no script for this provider, or the deployment does not offer it), `not_enabled_for_workspace` (the workspace is not on the pilot list), `oauth_available` (the provider has a sign in of its own, which wins), `script_unhealthy` (its script failed in recent sessions and the rail is paused), `vendor_unavailable` (the browser vendor failed to create sessions and the rail is paused), `monthly_cap` (the monthly browser minutes are used) and `sign_in_not_wired` (the sign in token cannot be signed).
      - `caveat` string: A note on an AVAILABLE rail: what the end user must create or qualify for before it works (a GoDaddy Personal Access Token; Namecheap's API eligibility and IP allowlist). Show it; do not treat it as unavailability.
      - `flow` 'oauth2' | 'vercel_integration' | 'porkbun_handoff' | 'ovh_consent' | 'mcp_oauth': Available `oauth` rail only: the sign-in it runs, so a client can label its button (see `POST /connections/{id}/oauth:start`).
      - `managed` boolean: Available `oauth` rail only: the connect will keep its credential (managed mode). Omitted when it will not.
      - `regions` string[]: Available `ovh_consent` rail only: the OVHcloud API regions this deployment can run the consent in, the default first.
    - `manual` RailInfo
      - `available` boolean, required
      - `reason` 'unknown_provider' | 'provider_has_no_oauth' | 'oauth_client_not_registered' | 'not_discovered' | 'template_not_onboarded' | 'redirect_unavailable' | 'rail_disabled' | 'custom_routing' | 'no_adapter' | 'unregistered' | 'platform_subdomain' | 'provider_not_listed' | 'not_enabled_for_workspace' | 'oauth_available' | 'script_unhealthy' | 'vendor_unavailable' | 'monthly_cap' | 'sign_in_not_wired': Set exactly when `available` is false. `provider_has_no_oauth`: the provider publishes no OAuth rail. `oauth_client_not_registered`: it does, but this deployment has not set it up: no client registered, or an MCP server sign-in not listed in `MCP_SIGNIN_PROVIDERS` (operator task). `not_discovered`: no Domain Connect discovery answer. `template_not_onboarded`: the provider speaks Domain Connect but has not onboarded our template. `redirect_unavailable`: the provider requires our return link on a one-click apply and the template cannot carry it yet. `rail_disabled`: the sync rail is off in this deployment. `custom_routing`: the application routes domains to its own infrastructure (a connect target) and the one-click templates point domains at the CustomDomain™ edge. `no_adapter`: no write adapter for the API-key rail. `unregistered` / `platform_subdomain`: the domain is blocked (see `blocked_reason`). For `rails.cloud_browser` only: `provider_not_listed` (no script for this provider, or the deployment does not offer it), `not_enabled_for_workspace` (the workspace is not on the pilot list), `oauth_available` (the provider has a sign in of its own, which wins), `script_unhealthy` (its script failed in recent sessions and the rail is paused), `vendor_unavailable` (the browser vendor failed to create sessions and the rail is paused), `monthly_cap` (the monthly browser minutes are used) and `sign_in_not_wired` (the sign in token cannot be signed).
      - `caveat` string: A note on an AVAILABLE rail: what the end user must create or qualify for before it works (a GoDaddy Personal Access Token; Namecheap's API eligibility and IP allowlist). Show it; do not treat it as unavailability.
      - `flow` 'oauth2' | 'vercel_integration' | 'porkbun_handoff' | 'ovh_consent' | 'mcp_oauth': Available `oauth` rail only: the sign-in it runs, so a client can label its button (see `POST /connections/{id}/oauth:start`).
      - `managed` boolean: Available `oauth` rail only: the connect will keep its credential (managed mode). Omitted when it will not.
      - `regions` string[]: Available `ovh_consent` rail only: the OVHcloud API regions this deployment can run the consent in, the default first.
    - `cloud_browser` RailInfo
      - `available` boolean, required
      - `reason` 'unknown_provider' | 'provider_has_no_oauth' | 'oauth_client_not_registered' | 'not_discovered' | 'template_not_onboarded' | 'redirect_unavailable' | 'rail_disabled' | 'custom_routing' | 'no_adapter' | 'unregistered' | 'platform_subdomain' | 'provider_not_listed' | 'not_enabled_for_workspace' | 'oauth_available' | 'script_unhealthy' | 'vendor_unavailable' | 'monthly_cap' | 'sign_in_not_wired': Set exactly when `available` is false. `provider_has_no_oauth`: the provider publishes no OAuth rail. `oauth_client_not_registered`: it does, but this deployment has not set it up: no client registered, or an MCP server sign-in not listed in `MCP_SIGNIN_PROVIDERS` (operator task). `not_discovered`: no Domain Connect discovery answer. `template_not_onboarded`: the provider speaks Domain Connect but has not onboarded our template. `redirect_unavailable`: the provider requires our return link on a one-click apply and the template cannot carry it yet. `rail_disabled`: the sync rail is off in this deployment. `custom_routing`: the application routes domains to its own infrastructure (a connect target) and the one-click templates point domains at the CustomDomain™ edge. `no_adapter`: no write adapter for the API-key rail. `unregistered` / `platform_subdomain`: the domain is blocked (see `blocked_reason`). For `rails.cloud_browser` only: `provider_not_listed` (no script for this provider, or the deployment does not offer it), `not_enabled_for_workspace` (the workspace is not on the pilot list), `oauth_available` (the provider has a sign in of its own, which wins), `script_unhealthy` (its script failed in recent sessions and the rail is paused), `vendor_unavailable` (the browser vendor failed to create sessions and the rail is paused), `monthly_cap` (the monthly browser minutes are used) and `sign_in_not_wired` (the sign in token cannot be signed).
      - `caveat` string: A note on an AVAILABLE rail: what the end user must create or qualify for before it works (a GoDaddy Personal Access Token; Namecheap's API eligibility and IP allowlist). Show it; do not treat it as unavailability.
      - `flow` 'oauth2' | 'vercel_integration' | 'porkbun_handoff' | 'ovh_consent' | 'mcp_oauth': Available `oauth` rail only: the sign-in it runs, so a client can label its button (see `POST /connections/{id}/oauth:start`).
      - `managed` boolean: Available `oauth` rail only: the connect will keep its credential (managed mode). Omitted when it will not.
      - `regions` string[]: Available `ovh_consent` rail only: the OVHcloud API regions this deployment can run the consent in, the default first.
  - `recommended_rail` 'oauth' | 'domain_connect' | 'cloud_browser' | 'api_key' | 'manual': The rail the server recommends, 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 or carries a caveat. Absent when `blocked_reason` is set. The console and the widget follow this field so the surfaces never disagree.
  - `end_user_automatic` boolean: 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': 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: 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` string[]: The nameserver delegation the detector observed (normalized hostnames).
  - `managed_dns` ManagedDNSAvailability: Whether CustomDomain™ can host this domain's zone (managed DNS). Always present on the domains:check response.
    - `available` boolean, required
    - `reason` '' | 'disabled' | 'platform_subdomain' | 'unsupported_tld' | 'reserved', required: Empty when available. `disabled`: managed DNS is off on this deployment. `platform_subdomain`: the name belongs to a hosting platform (someone.github.io), so its nameservers can't be changed. `unsupported_tld`: there is no registrable domain whose nameservers a registrar can change. `reserved`: CustomDomain™'s own domain.
  - `integration_warnings` object[]: 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.
    - `code` 'supplied_apex_unrealizable': `supplied_apex_unrealizable`: you supplied a record for the zone apex pointing at your own host, and this domain's DNS provider has no ALIAS/ANAME/CNAME-flattening type, so a hostname cannot sit at a root there. Providers WITH flattening (Cloudflare, Route 53, DNSimple) accept it unchanged.
    - `message` string: Human-readable, with the available alternatives named.
  - `record_conflicts` object[]
    - `kind` string: value-mismatch | cname-collision | spf-merge
    - `host` string
    - `type` string
    - `existing` string
    - `desired` string
  - `conflict_tolerance` integer: Provider's automated-write conflict threshold (0/absent = no modeled limit).
  - `will_fallback_to_manual` boolean: 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.

## Other responses

- `400`: Error envelope: {code, title, details}.
- `401`: Error envelope: {code, title, details}.
- `403`: Error envelope: {code, title, details}.
- `404`: Error envelope: {code, title, details}.

---

[API](https://skmtc.dev/customdomain/apis/customdomain-control-plane-api.md) · [All operations](https://skmtc.dev/customdomain/apis/customdomain-control-plane-api/llms.txt) · [OpenAPI document](https://skmtc.dev/customdomain/apis/customdomain-control-plane-api/revisions/34d89cb7a7cd?raw)
