# Create a domain connection (idempotent per app + domain)

> Create a domain connection (idempotent per app + domain): CustomDomain™ API reference in the connections group. Covers parameters, request body and responses.

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

## `POST https://api.customdomain.ai/v1/connections`

Starts connecting `domain` under the calling application and returns the connection plus its authoritative desired record set. Idempotent: if the app already has a non-failed connection for the domain, that existing connection is replayed with `200`.

A widget token is never handed another end user's connection. When the application already has a connection for the domain (live, or failed and about to be resumed) that names another end user than the token's `end_user_ref`, or names one while the token names none, the call answers `403` `token_wrong_end_user` and shows nothing of it: no id, no field, and a failed connection is not resumed. Mint the token with the end user's `end_user_ref`. The replay is unchanged for the end user's own token, for a connection that names no end user (an API key made it), for an API key, an app-scoped token and a delegated agent token.

A domain another application already serves live is refused with `409 domain_already_connected` before anything is written: move it by disconnecting it there first. When the ownership TXT flow is on the claim is accepted instead, and the ownership challenge decides it.

Callable from the widget (a widget JWT) or straight from your server with an API key, in which case the connection is created on the key's application, or on `application_id` when that names another application of the same tenant.

A delegated agent token needs `domains:connect` and creates the connection on its own application. Its answer, a replayed connection included, carries no `end_user_ref`, the platform's id for its end user. It cannot set the end user's e-mail address: a body with a non-empty `end_user_email` is refused with `403`, and the platform's API key sets it.

A name under a zone CustomDomain™ already hosts joins it without a new move, and the `201` shows it already `live` when its records resolve. The registrable domain's zone must be `active` (delegated to us) and hosted for this application. A widget token joins only a zone that is its own by the rule every managed DNS call of a widget token follows: the new connection is the token's, and so is every connection already linked to the zone (a connection that names an end user is the token's when its `end_user_ref` is the token's; one that names none is the token's only for a name the token is bound to). The connection that started the zone does not decide it, so a zone that an API key, the console or another end user also linked a name to is not the token's. The application must still offer DNS hosting on CustomDomain™'s own DNS. An API key or an app scoped token joins its own application's zone. A delegated agent token, a widget token that does not own the zone, and every other zone change nothing: the name proves control by DNS as always, and no record is written into a zone that is not the caller's. The records written are in the audit log as `dns.hosted_zone_joined`.

A workspace with no subscription yet connects its first ten domains, or the number an operator set, without one (`first_domain_free` and `free_domains` on the billing reads): a retry of the last of them is still replayed with `200`, and the next domain is refused with `402 billing_required` until the workspace starts its trial. A domain that is still waiting for its records can be removed with `DELETE /connections/{id}`, which frees its place at once.

### Authentication

`widgetJWT`, `apiKey`, `agentToken`

### Request body (JSON)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `domain` | string | yes |  |
| `application_url` | string | no | Origin the edge reverse-proxies this connection's traffic to (Power). Validated on the way in: it must be an absolute `http(s)` URL with a public host; private, loopback and cloud-metadata targets are rejected with a `400`. Power is part of the Growth plan and up: on a plan without it the create is refused with `402 plan_upgrade_required` and nothing is stored. Omit to leave the edge on its own router. |
| `www_redirect` | boolean | no | Serve an apex↔www redirect for this connection. |
| `override_spf` | boolean | no | Replace an existing SPF record instead of merging into it. Honoured only for providers whose connection check reports `spf_override_support` as true; for every other provider the existing record is merged, as when this is false. |
| `validate_dmarc` | boolean | no | Accepted, stored with the connection and echoed back. It is not acted on yet: DMARC is not checked during verification today. |
| `validate_caa` | boolean | no | Adds the two apex CAA records that authorize Let's Encrypt (`issue` and `issuewild`) to the connection's desired record set, skipping any already present. No separate CAA check is run against the domain's existing records. Edge routing only: a domain routed to your own infrastructure (a connect target) gets its certificate there, so no CAA records are added for it. |
| `monitor` | boolean | no | Enrol the domain in drift monitoring: once the connection goes live the control plane registers a drift watch and the hourly sweeper re-checks the connection's DNS records, emitting `domain.record_missing` / `domain.record_restored` when they change. Unlike the four flags above this one DEFAULTS TO TRUE: omitting it enrols the domain. That is deliberate back-compat: the watch used to be registered unconditionally, so every connection created before this flag was honoured is monitored, and every existing integrator omits the field. Send `false` to opt a connection out; the value is echoed on the connection and is the only thing that decides whether the watch is registered. The plan's Monitor entitlement is not enforced today: monitoring is registered for every connection that has this on, regardless of plan. |
| `batch_id` | string | no | Caller-supplied id correlating every domain connected in one multi-domain session. Stamped on each connection and echoed back; empty for a standalone connect. Set once at create. |
| `end_user_ref` | string | no | The integrator's OWN identifier for the end user this connect is attributed to, so the console can render "Connected by …" and search by customer. Empty ⇒ a console/direct connect. Set once at create and never mutated. It binds a widget token and saved access to the connection, so it is an id and not a name: to name the client a domain is for, send `client_label`. |
| `client_label` | string | no | The name of the client or customer this domain is for, shown in the console beside the domain and used by its client filter. Trimmed to one line, at most 200 characters, with characters that draw nothing dropped; a name with nothing left stores no name. Unlike `end_user_ref` nothing is bound to it, and `PATCH /connections/{id}` changes it later. Only an API key or an `app`-scoped token may send it: an end user's widget token or a delegated agent token that does is refused with `403`. A replay of a domain that already exists keeps the name it has. |
| `end_user_email` | string | no | Optional. The email address of the end user this domain is connected for. After a Move they get one email with the new DNS records, sent on your behalf and naming your application (at most 3 per connection in 30 days). It wins over an address given when the token was minted, is stored only on the connection, and is shown back only in this call's response and on `GET /connections/{id}` with an API key. Only an API key replaces an address the connection already has: a widget token's create keeps the stored one (as `PUT /connections/{id}/end-user-email` answers it with `409`), and a delegated agent token that sends one is refused with `403`, since the reconnect email goes to this address and carries a link that signs in as the end user. For the same reason a widget token bound to another end user than the connection's (`end_user_ref`) stores no address, neither this one nor one given when the token was minted, and is not asked for one. |
| `application_id` | string | no | API key callers only: create the connection on this application of the key's tenant. A widget or agent token may only name its own application (anything else is a `403`). |

### Responses

| Status | Description |
| --- | --- |
| `200` | Existing connection replayed (idempotent) |
| `201` | Connection created |
| `400` | Error envelope: {code, title, details}. |
| `401` | Error envelope: {code, title, details}. |
| `402` | Refused because of the workspace's billing. `billing_required`: the workspace has no active or trialing subscription (a new workspace that has not started its trial, a Free workspace past its grace period, a comp that ended, or a subscription Stripe marked unpaid or canceled). It stops a new connection, the start of a domain purchase and the first live credential (an application, an API key or a rotated client secret). Domains already connected keep working, and an owner or admin sets up billing in the console; `details` says where. A new workspace's first domain is the exception: while it has fewer pending, propagating or live connections than its free domains (ten, unless an operator set another number: `free_domains`), a connect and the start of a purchase go through (`first_domain_free`), and the next one is refused with a `title` saying a card is needed to connect more domains. Credentials still need the trial. `quota_exceeded` (connections only): a plan that is refused past its quota (Starter, and the retired Free) is at it and the deployment enforces quotas. `connections_unavailable`: what a connect-scoped widget token, the tenant's own end user, gets instead of either (and instead of `403 workspace_suspended`), with neutral wording, so the tenant's customers are never told about the tenant's billing or account. A delegated agent token gets the full wording, and `GET /billing/standing` tells it beforehand whether a new connection would be refused. |
| `403` | `forbidden` (the credential may not do this, or `application_id` or `end_user_ref` does not match the token's), `token_wrong_end_user` (a widget token: the application already has a connection for this domain that names another end user, or names one while the token names none; nothing of it is shown or changed, so mint the token with this domain's `end_user_ref`), or `workspace_suspended` (see the `ForbiddenOrSuspended` response). |
| `409` | `domain_already_connected`: another application already serves this domain live. Disconnect it there first, then connect it here. |
| `422` | `apex_not_supported`: the domain is a root domain, the application routes to its own infrastructure, the domain's DNS provider has no ALIAS-style record, and the application has no `apex_ipv4` to use instead. Connect a subdomain such as `www`, or add root addresses to the application's routing (`PUT /applications/{id}/connect-target`). |

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

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