# Move the domain's nameservers, with the end user's consent

> Move the domain's nameservers, with the end user's consent: CustomDomain™ API reference in the nameservers group. Covers parameters, request body and responses.

Source: https://docs.customdomain.ai/docs/api-reference/nameservers/migrateNameservers

## `POST https://api.customdomain.ai/v1/connections/{id}/nameservers:migrate`

Starts moving the nameservers of the connection's registrable domain (acme.com, also for shop.acme.com) to the application's DNS host, or resumes the domain's open migration with what the call brings (a key, `zone_ready`, `skip_zone_check`). 201 when a migration was created, 200 when one was resumed. The response is the migration as far as it got in this call: with a registrar key and no DNSSEC it is often already `propagating` or `active`.

**Where the nameservers go** is the application's `nameserver_migration.target` (PATCH `/applications/{id}`): `managed` (CustomDomain™ hosts the zone), `dns_host` (a zone we create in the platform's own DNS host account) or `custom` (the platform's own nameservers; the zone must already answer for the domain). For `managed` and `dns_host` the domain's records are copied first, mail included, so nothing breaks at the switch: the whole zone through the old DNS host's API when we hold a key there, else from public DNS, and then the move waits in `needs_review` until a person confirms the copy (`copy_reviewed`). An API key may pass `target` for `managed` or `custom`; `dns_host` is always the application's own account. A delegated agent token may name only the application's own target and may not send `skip_zone_check` (`403 target_not_allowed`): with a registrar account linked, nameservers an agent chose would hand it the whole domain in one call.

**Where they change**: at the domain's registrar, looked up over RDAP for every move (`registrar`), never at the DNS host because it hosts the DNS. A key, an account, a cloud browser sign in and the instructions all go to that registrar; when RDAP names none, the detected DNS host is used as a guess and `next_step` names no company.

**How they change** (`path`): `api`, the registrar's own API with a key the customer created (`credential` with `provider`, used once and never stored, or `provider_account_id`, a connected account; the connection's linked account is used when there is one); `agent`, the cloud browser, where the end user signs in to their registrar and approves the exact nameservers (start it with `POST /connections/{id}/browser:start` and `purpose: nameservers`); `manual`, the person sets them with the values in `next_step`. There is no standard a third party can use to ask a registrar for this (Domain Connect edits records only; CDS and CSYNC need the current DNS operator and DNSSEC), so these three are the ways.

**A lost answer**: a registrar's error is not taken at its word, since a timeout can come after the registrar acted. The registrar (then the registry) is read back: the nameservers asked for mean the change happened; others mean it didn't (`registrar_refused`); no answer leaves the move in `setting` with `waiting_for: registrar_confirm`, still possible to move back, and the next check reads back first.

**DNSSEC**: when the registry publishes a DS record for the domain, the nameservers do not move until it is gone and its TTL has passed, or the domain would stop resolving. With a connected account (lasting access) whose registrar API removes DS records, we remove it; otherwise the migration waits in `needs_dnssec_off` for the person (or the cloud browser's `remove_dnssec` step).

**A widget token** (the end user) may start a migration only when the application's `nameserver_migration.mode` is `offer` or `prefer`, must send `consent` (the id and version of the consent text the person agreed to; stored with the time, never their address), and may not send `target`, `credential`, `provider_account_id`, `zone_ready` or `skip_zone_check`. A delegated agent token needs `domains:connect` (the MCP tool also asks the person to confirm first).

### Authentication

`apiKey`, `widgetJWT`, `agentToken`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes |  |

### Request body (JSON)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `target` | object | no | Where a domain's nameservers move. `managed`: CustomDomain™ hosts the zone (route 1). `dns_host`: a zone we create in the platform's own DNS host account, a connected provider account with continuous access at Cloudflare, Amazon Route 53, Google Cloud DNS or Azure DNS (route 2a). `custom`: the platform's own nameservers, 2 to 13 host names, whose zone the platform runs (route 2b). |
| `path` | `api`, `agent`, `manual` | no | How to change the nameservers. Omitted: `api` with a key, else `agent` where the cloud browser can, else `manual`. |
| `provider` | string | no | The registrar a `credential` belongs to (an adapter id such as `godaddy`). When RDAP names the domain's registrar, a key at another company is refused (`registrar_mismatch`). |
| `provider_account_id` | string | no | A connected provider account at the domain's registrar. One at another company than the registrar RDAP names is refused (`registrar_mismatch`). |
| `credential` | object | no |  |
| `zone_ready` | boolean | no | Target `custom`: the platform's nameservers now serve the zone. |
| `skip_zone_check` | boolean | no | Target `custom`: do not check that the new nameservers answer for the zone before moving. |
| `copy_reviewed` | boolean | no | A person checked the copied records (state `needs_review`), so the move may go on. Sent with the start by an API key, it accepts a copy read from public DNS in advance; it does not cover a copy that read nothing, which always waits for a person. A widget token may send it only to a move already in `needs_review`. |
| `consent` | object | no | The consent text the end user agreed to. Required from a widget token, and it must name a text the widget shows (`nsm-consent`, versions 1 to 3), so a stored consent always names the words that were on the screen. |

### Responses

| Status | Description |
| --- | --- |
| `200` | The domain's open migration, resumed. |
| `201` | A migration was created. |
| `400` | Error envelope: {code, title, details}. |
| `401` | Error envelope: {code, title, details}. |
| `403` | `forbidden`: the credential may not do this. `workspace_suspended`: CustomDomain™ support suspended the workspace. While it is suspended, new connections (one by one or in bulk, where each domain fails on its own), every connect step on a domain it already has (the credentialed apply, a provider sign in to connect, the cloud browser, Domain Connect, the Route 53 access role, the hosted zone and `:reapply`) and the start of a domain purchase are refused; reads, rechecks, removals and the sign in to remove a domain keep working, and domains already connected keep their records. A connect-scoped widget token, the tenant's own end user, gets `402 connections_unavailable` instead, the neutral answer it gets for a billing refusal. Support resumes the workspace; nothing in the API does. |
| `404` | Error envelope: {code, title, details}. |
| `409` | The domain already uses these nameservers, its nameservers are being moved for another account, or another change to the migration is in progress. |
| `422` | `nameserver_migration_unavailable` (this domain's nameservers can't be moved; `details` says why), `nameserver_api_unsupported` (the registrar has no API call that changes nameservers), `nameserver_agent_unavailable` (path `agent` asked for, but the cloud browser can't change this registrar's nameservers), `registrar_locks_nameservers` (the domain is registered at a registrar that requires its own nameservers, Cloudflare Registrar), `registrar_mismatch` (the key or account is at another company than the registrar RDAP names), `zone_host_unsupported` or `dns_host_not_configured`. |
| `503` | Error envelope: {code, title, details}. |

### `200` response body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `connection_id` | string | no |  |
| `zone` | string | yes | The registrable domain whose nameservers move. |
| `state` | `pending`, `copying`, `needs_review`, `needs_dnssec_off`, `waiting_ds`, `setting`, `propagating`, `active`, `failed`, `rolled_back` | yes | `pending`: target `custom` waits for the platform's zone. `copying`: the zone is being created and the records copied; a copy whose write to the DNS servers failed is retried, never switched to. `needs_review`: the copy is not proven complete (it was read from public DNS, which can't list a zone, or some records could not be carried over); a person checks `copy_warnings` and confirms with `copy_reviewed`. `needs_dnssec_off`: DNSSEC must be turned off at the registrar first. `waiting_ds`: DNSSEC is off; waiting for the registry to stop publishing the DS record and its TTL to pass. `setting`: the nameservers are being changed (`waiting_for` says by whom). `propagating`: changed; waiting for the registry to list every new nameserver (checked every 5 minutes for 2 hours, then hourly up to 48 hours). `active`: the registry lists them (re-checked every 6 hours; a move away is `failed` with `delegation_drift`). `failed`: see `failure_code`. `rolled_back`: moved back. |
| `path` | `api`, `agent`, `manual` | yes |  |
| `target` | `managed`, `dns_host`, `custom` | yes |  |
| `dns_host` | string | no | The DNS host of target `dns_host` (an adapter id such as `cloudflare`). |
| `provider` | string | no | Where the nameservers are changed: the registrar's provider id (`registrar.provider`), or, when `registrar` is null, the DNS host the domain used, as a guess (then `next_step` says "where the domain is registered" and names no company). |
| `registrar` | object | yes | Who the domain is registered with (RDAP, or the account or key the caller brought); null when unknown. |
| `old_dns_host` | string | no | The DNS host the domain used when the move started (a provider id such as `cloudflare`). |
| `old_nameservers` | array of string | yes | What the registry listed when the move started; a rollback sets these. |
| `new_nameservers` | array of string | yes |  |
| `observed_nameservers` | array of string | yes | What the registry listed at the last check. |
| `waiting_for` | `zone_ready`, `zone_served`, `copy_review`, `dnssec_off`, `ds_clear`, `credential`, `registrar_confirm`, `browser_session`, `registrar`, `delegation` | no |  |
| `next_step` | string | no | One sentence for a person. |
| `failure_code` | `zone_copy_failed`, `zone_not_served`, `dnssec_timeout`, `ds_removal_failed`, `credential_refused`, `registrar_refused`, `registrar_locked`, `changed_elsewhere`, `agent_failed`, `delegation_timeout`, `delegation_drift`, `internal` | no | `changed_elsewhere`: the registrar listed other nameservers than the ones the move started from, so the change did not overwrite them. |
| `failure_detail` | string | no |  |
| `records_copied` | integer | yes |  |
| `email_records` | integer | yes | How many of the copied records carry mail (MX, SPF, DKIM, DMARC). |
| `email_kinds` | array of `mx`, `spf`, `dkim`, `dmarc` | yes |  |
| `copy_source` | `api`, `discovery`, `existing_zone` | no | How the records were copied. `api`: the whole zone, read through a key we hold at the DNS host the domain used (a kept sign in or a connected account there); proven complete. `discovery`: read from public DNS at the names domains commonly use; a person confirms it. `existing_zone`: the platform's zone already held records, which were kept. |
| `copy_warnings` | array of string | yes | What a person should check before the switch: records left out (a Cloudflare proxied record answers with Cloudflare's addresses, not the origin), lookups that went unanswered, forwarding that stays at the old host. |
| `copy_reviewed_at` | string | no | When a person confirmed a copy that is not proven complete. |
| `ds_present` | boolean | yes | The registry published a DS record (DNSSEC was on) during the move. |
| `ds_removed_at` | string | no |  |
| `ds_cleared_at` | string | no |  |
| `can_rollback` | boolean | yes |  |
| `rollback_path` | `api`, `agent`, `manual`, `none` | no | How the move back was made: `api` (the registrar's API, done), `agent` (the person signs in to the registrar in the cloud browser with `purpose: nameservers` and approves it), `manual` (`next_step` says what to set or choose), `none` (nothing had changed yet). |
| `rollback_targets` | array of `previous`, `registrar_default` | yes | The move backs possible now; `previous` is left out once the old nameservers no longer answer for the zone. |
| `rollback_default` | `previous`, `registrar_default` | no | Where a move back without `rollback_target` goes. |
| `rollback_target` | `previous`, `registrar_default` | no | Where the move back went. |
| `rollback_set_at` | string | no | When the move back was made at the registrar; absent while a person or the cloud browser still has it to do. |
| `old_dns_serves` | boolean or null | yes | Whether the old nameservers still answered for the zone when last asked (once a day while active, and at a move back); null when not asked yet. |
| `consent` | object | no |  |
| `actor` | `end_user`, `api_key`, `console`, `agent` | no |  |
| `created_at` | string | yes |  |
| `set_at` | string | no | When the nameservers were changed at the registrar (or first seen changed). |
| `active_at` | string | no |  |
| `failed_at` | string | no |  |
| `rolled_back_at` | string | no |  |
| `drifted_at` | string | no |  |
| `checked_at` | string | no |  |
| `next_check_at` | string | no |  |
| `updated_at` | string | yes |  |

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