# Change a domain's client name, www redirect or forwarding

> Change a domain's client name, www redirect or forwarding: CustomDomain™ API reference in the connections group. Covers parameters, request body and responses.

Source: https://docs.customdomain.ai/docs/api-reference/connections/updateConnection

## `PATCH https://api.customdomain.ai/v1/connections/{id}`

Changes the settings of a domain a person edits after it is set up: `client_label` (the name of the client it is for), `www_redirect` (a root domain's redirect to `www`) and `redirect_to` (forward every visit to another site). Send only what changes. A body that changes nothing answers `200` and writes nothing.

**Who may call it.** A server side credential only: an API key of a member who may change domains (member, admin or owner; a viewer's key is `403`), or an `app`-scoped token for a connection of its own application. An end user's `connect`-scoped widget token is refused `403`, its own connection included, and so is a delegated agent token: the name, the `www` redirect and above all the forward decide where the domain's visitors go, so the platform that owns the domain decides, not the person connecting it or an agent acting for them. A connection of another workspace answers `404`.

A test mode domain takes `client_label` only (`403` `sandbox_not_allowed` for the redirects).

**The www redirect** changes the records the domain needs. A zone we host is rewritten at once; a domain whose records were written through a connected provider account is rewritten when `move_rewrite` is on; any other domain is reported with `connection.records_outdated` and the answer's `customer_action_required` is `true` when a record was added. A live domain stays live while that record is missing.

**Forwarding** is held to one validator, the same one `POST /connections/{id}/power` and `/power:repower` use for their `redirect_to`: see `redirect_to` in the body. A value equal to the one stored is not checked again.

Every change is recorded in the audit log as `connection.updated`, with the before and after of the fields that changed, by the member or key that made it (`widget:<application id>` for an `app`-scoped token).

**Two requests at once.** Redirect changes of one workspace are made one at a time, each checked against the domains as they are when its turn comes, so two changes made together cannot make a circle between them: the second is refused (`422` `redirect_loop`). A change that waits too long for its turn answers `503` `redirects_busy` (`Retry-After`) and changes nothing. A request writes only the settings it changes, and a value it sends back as it read it is not a change, so a setting another request has changed since is not undone.

**When something fails.** The redirects are stored before the name. When `client_label` is sent with `www_redirect` or `redirect_to` and only the name cannot be written, the answer is `500`, the redirect change is stored and is in the audit log, and sending the same body again writes the name.

### Authentication

`apiKey`, `widgetJWT`

### Parameters

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

### Request body (JSON)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `client_label` | string | no | The name of the client or customer the domain is for. Characters that draw nothing (a right to left override, a zero width space, the blank fillers, variation selectors, private use characters) are dropped, control characters and runs of white space become one space, and the name is trimmed. Only `""` clears it: a value that is not empty but has nothing readable left (white space, or only invisible characters) is `400`, so it cannot take a name off. More than 200 characters is `400`. |
| `www_redirect` | boolean | no | Send visitors of the root domain (`acme.com`) to `www.acme.com`. Root domains only: `true` on a subdomain is `422` `www_redirect_apex_only`. Available on every plan. Turning it on adds a `www` record to the records the domain needs; turning it off takes it out, and the edge then answers for `www.acme.com` only if you connected it as its own domain. Not for a test mode domain (`403` `sandbox_not_allowed`). |
| `redirect_to` | string | no | Where the edge sends every visit to the domain (a `301`, keeping the visit's path and query): a plain `https://` address of a public domain, `""` to clear it. It needs edge serving, which comes with Growth and up: `402` `plan_upgrade_required` on a plan without it and `503` where the deployment has it off (clearing a forward needs neither). `400` for an address that is not https (`http:`, `javascript:`, `data:`, a scheme-less address), has a username or password (`https://acme.com@evil.example`), names an IP address, a name with no dot or a reserved name (`localhost`, `.local`, `.internal`, `.test`), or contains spaces, backslashes or hidden characters. The port is read as a number and stored once: `:443` is dropped and a leading zero is not kept (`:0443` is `443`), and a target that names this domain on port 80 or 443 is the domain itself, as the edge reads it. `422` `redirect_target_not_allowed` for a domain another workspace has connected or claimed (unless your own workspace serves it), and `422` `redirect_loop` when the target is the domain itself or a visit would come back to a host it has been through, through this domain's `www` redirect or any forward of your own domains, or goes through more than 8 forwards in a row. Not for a test mode domain. |

### Responses

| Status | Description |
| --- | --- |
| `200` | The connection as it is now, with its records |
| `400` | `invalid_request`: an unknown field, a field of the wrong type, no field at all, a `client_label` over 200 characters or with nothing in it a person can read (only `""` clears a name), or a `redirect_to` that is not a plain https address of a public domain (the reason is in `details`). `payload_too_large`: see the error envelope. |
| `401` | Error envelope: {code, title, details}. |
| `402` | Error envelope: {code, title, details}. |
| `403` | `forbidden`: a viewer's key, an end user's widget token or an agent token. `sandbox_not_allowed`: `www_redirect` or `redirect_to` on a test mode domain. |
| `404` | Error envelope: {code, title, details}. |
| `409` | `conflict`: the domain is being removed (also when the removal starts while the change waits its turn). |
| `422` | `www_redirect_apex_only`: `www_redirect: true` on a subdomain. `redirect_target_not_allowed`: `redirect_to` names a domain another workspace has connected or claimed, whether or not it is serving yet (a domain your own workspace serves is yours to forward to, whoever else has a pending or failed claim on it). `redirect_loop`: a visit would go round in a circle (taking a forward off a domain whose `www` redirect is on counts: the visit then goes to the `www` name), or through more than 8 forwards in a row (the `title` says "too many forwards" for that); `details` lists the hosts, naming only the ones you may see (your workspace's, and for an `app`-scoped token its own application's) and `another domain` for the rest. |
| `500` | `internal_error`: a store failed, or a check could not be made, so the change was refused (a forward is never stored when the owner of its domain cannot be read). When only `client_label` could not be written, the redirect change sent with it is stored. |
| `503` | `redirects_busy`: another change to this workspace's forwarding is still being saved (redirect changes of one workspace are made one at a time), and this one waited for it in vain. Nothing was changed; send it again after the `Retry-After` seconds. `service_unavailable`: edge serving is off in this deployment, so a forward cannot be set. |

### `200` response body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `application_id` | string | yes |  |
| `domain` | string | yes |  |
| `host` | string | no | subdomain label; empty means the apex |
| `provider_id` | string | no | detected / applied DNS provider |
| `setup_type` | `automatic`, `manual`, `async`, `mcp` | yes | How a connection reaches live. |
| `status` | `pending`, `propagating`, `live`, `failed` | yes |  |
| `managed` | boolean | no | `true` when the connection opted into **managed mode**: the control plane holds a durable, encrypted async grant and can re-apply (`POST /v1/connections/{id}:reapply`) or revert (`DELETE /v1/connections/{id}`) the template server-side without re-consent. Omitted (treated as `false`) for the default use-once connection. |
| `created_at` | string | yes |  |
| `sandbox` | boolean | no | `true` when the connection belongs to a test mode application. Its DNS is simulated, nothing real is read, written or served, and it counts for nothing (no free domain, quota, billing or metering). Copied from the application at create and never changed. Omitted (treated as `false`) on a real connection. |
| `last_checked_at` | string | no | When the propagation poller last examined this connection. Omitted until the first check. |
| `www_redirect` | boolean | no | Serve an apex↔www redirect. Echoed from create. |
| `override_spf` | boolean | no | Replace an existing SPF record instead of merging into it, where the provider supports it. Echoed from create. |
| `validate_dmarc` | boolean | no | Stored and echoed from create; not acted on yet. |
| `validate_caa` | boolean | no | Whether the Let's Encrypt CAA records are part of the desired record set. Echoed from create. |
| `monitor` | boolean | no | Whether the domain is enrolled in drift monitoring. Echoed from create, where it defaults to true when omitted, and honoured: the drift watch is registered when the connection goes live only if this is true. Connections created before the flag was honoured read true (they were monitored unconditionally, and were backfilled), so this is false only where a caller explicitly opted out. |
| `batch_id` | string | no | The multi-domain session this connection was created in. Omitted for a standalone connect. |
| `end_user_ref` | string | no | The integrator's own identifier for the end user who connected this domain. Set once at create and never changed: it binds widget tokens and saved access to the connection. Omitted for a console/direct connect, and from every answer a delegated agent token reads. The name of the client a domain is for is `client_label`. |
| `client_label` | string | no | The name of the client or customer the domain is for, set at create or with `PATCH /connections/{id}`. Omitted when none is set. It is the platform's own note about the client: it is left out of every answer an end user's widget token or a delegated agent token reads, and an API key or an `app`-scoped token reads it. |
| `retried_at` | string | no | When a human (or the create-path resume, or real edge traffic) last asked for this connection to be re-verified. The retry clock is max(created_at, retried_at): a revived connection gets a fresh window from here, not from its original creation. Absent when it was never retried. |
| `routing` | object | no | Where this domain points: its application's routing when the connection was created. A change to the application's routing moves it only while it is still waiting for DNS; once connected it keeps this until `POST /v1/connections/{id}:retarget` moves it. Omitted only for a connection created before this was recorded, which follows its application's current routing. |
| `records_source` | `integrator` | no | Present as `integrator` when the connection's desired record set was supplied through `PUT /v1/connections/{id}/records` rather than authored by us. Omitted for the usual server-synthesized or Domain Connect template-materialized set. Such a connection cannot use the Domain Connect rail, which applies our published templates at the provider. |
| `created_via` | `""`, `widget`, `console`, `api`, `agent`, `mcp` | no | Where the connection was created, recorded once at create and never changed. `widget`: an end user's connect-scoped widget token (the embedded widget in your product). `console`: the CustomDomain™ console. `api`: an API key, or an app-scoped token, from your server. `agent`: a delegated agent access token. `mcp`: the MCP server. Empty for connections created before this was recorded. A trusted server-side caller may name its surface with the `X-CustomDomain-Client` request header (`console` or `mcp`); the header is a label only and grants nothing. |
| `secure_root_domain` | boolean | no | The edge also issues/secures the registrable apex alongside this subdomain connection. Omitted (treated as `false`) when unset. |
| `redirect_to` | string | no | URL the edge 301s this host to (Power redirect target). Omitted when the connection is not a redirect. |
| `power_root_path_access` | array of string | no | URL paths the edge may static-serve at the root (Power root-path access). Omitted when none are configured. |
| `error_code` | string | no | The last failure reason recorded for this connection, from whichever path failed, not only the background poller. It is persisted by: the propagation poller / `:recheck` verification (e.g. `propagation_timeout`, `setup_incomplete`, `pending_expired`, `domain_already_connected`); the foreground apply on `POST /connections/{id}/apply` (e.g. `dns_write_failed`, `ProviderAuthenticationError`, `spf_merge_failed`, `apex_not_supported`, `forwarding_not_supported`, `porkbun_api_access_disabled`); the OAuth callback (which also records `forwarding_not_supported`); and the Domain Connect async callback (both e.g. `provider_unavailable`, `ProviderAuthenticationError`, `apply_failed`, `internal_error`, or the provider's own `error` parameter). Rejected request validation (`400`) is deliberately NOT recorded here: a malformed request is not a connection failure. Omitted when healthy or never failed; cleared when the connection next verifies. |
| `error_message` | string | no | Human-readable detail for `error_code`. Populated on the same paths and cleared alongside it. Omitted when there is none. |
| `write_rail` | `api_key`, `sign_in`, `domain_connect`, `domain_connect_async`, `route53_role`, `provider_account`, `managed_dns` | no | The rail that last wrote this connection's records. Omitted when no rail wrote them (a manual setup, or a connection from before this was recorded). It decides what removing the domain can do with the records (`GET /connections/{id}/removal`). |
| `provider_account_id` | string | no | The connected provider account whose credential wrote this connection's records and, when the account's access is `continuous`, keeps them (repairs and removal). Omitted when none. |
| `removal_started_at` | string | no | Set while the domain is being removed: the records are being taken out and the connection is deleted when that finishes. The domain no longer serves from this moment. Omitted otherwise. |
| `dns_hosting` | object | no | Who runs the domain's DNS: a zone CustomDomain™ hosts, or a move to one. It is read from the managed DNS zone the connection is linked to and the workspace's nameserver moves, never from the connection itself, so it is on the list and on `GET /connections/{id}` and nowhere else a connection appears. A linked zone decides first (`active` is hosted, `awaiting_delegation` is moving, `failed` is failed). Without one, a nameserver move of the workspace on the domain or a parent of it decides (`active` is hosted, a move still under way is moving, one that failed because the domain's nameservers moved away again is failed; a rolled back or otherwise failed move counts for nothing). A test mode connection is always `none`. |
| `records` | array of object | yes |  |
| `end_user_email` | string | no | The end user's email address, when one was given and the feature is on. Present only on the response of the call that created the connection with it, and on `GET /connections/{id}` with an API key. Never in a list, a webhook, or any read by a widget token. |
| `ask_end_user_email` | boolean | no | Present (true) on a create response only when the widget should offer the end user an optional email field: the feature is on, the caller is a widget token that may set one (not another end user's), the domain is not a test domain and the connection has no address yet. The widget then calls `PUT /connections/{id}/end-user-email`. Omitted otherwise. |
| `credential` | object | no | The lasting API key the cloud browser's agent made at the domain's DNS provider for this connection, on `GET /connections/{id}` only: its record, never the key. Omitted when the connection has none. |
| `reach` | object | no | How CustomDomain™ reaches this domain's DNS to write records, from the access we keep (the order we use it: a zone we host, a kept sign-in or Domain Connect authorization, a connected account, the Route 53 role) and the domain's DNS hosting. A domain whose DNS is hosted is reached by the hosting, whoever runs the zone: an old sign-in or account at the previous DNS provider no longer writes anywhere that serves it. |
| `customer_action_required` | boolean | yes | `true` when the change adds a record that CustomDomain™ does not write: the domain's DNS is not ours and no kept access can write it (a kept account with `move_rewrite` on does), so the person adds the `www` record shown in `records` at their DNS provider, and `connection.records_outdated` was sent. `false` for every other change, a hosted zone's and a rewrite's included. |

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