# Apply records & go live

> Apply a domain's records by provider sign-in, one-click setup, secure sign-in, an API key, by hand or DNS hosting, then watch it go live with its certificate.

Source: https://docs.customdomain.ai/docs/connect-flow/verify-and-go-live

Once a connection exists, get its [desired records](/docs/connect-flow/create-a-connection#response)
into the customer's DNS by one of the six
[ways to connect](/docs/connect-flow/overview#the-four-rails): sign in with the
provider (Rail C below), one-click setup (Rail A), [secure sign-in](#secure-sign-in),
an API key (Rail B), [add the records](#manual) by hand, or
[DNS hosting](#dns-hosting). All of them move the connection
`pending → propagating → live`.

## Rail C: OAuth into the provider (flagship)

```bash
curl -X POST https://api.customdomain.ai/v1/connections/<ID>/oauth:start \
  -H "Authorization: Bearer <WIDGET_JWT>" \
  -H "Content-Type: application/json" \
  -d '{"return_origin":"https://app.acme.example"}'
# → { "authorize_url": "https://<provider>/authorize?...", "provider": "cloudflare",
#      "flow": "oauth2", "managed": false, "access": "one_time",
#      "callback_origin": "<this API's public origin>" }
```

The body also takes `provider` and `region` where a provider needs them,
`access` (`continuous` or `one_time`) and `purpose` (`"remove"` to sign in once
more to take records off). `managed` and `access` in the response say whether
the sign-in will be kept (`continuous`) or discarded after this use
(`one_time`); a removal sign-in is never kept and echoes `purpose`. Without
`access`, a sign-in is kept wherever the deployment keeps that provider's
grants. The hosted service keeps them for Cloudflare, DigitalOcean, DNSimple,
Linode, Netlify, Porkbun, Vercel and WordPress.com, so there the answer above
reads `"managed": true, "access": "continuous"`; the example shows a
deployment that keeps none.

The widget opens `authorize_url` in a popup. The provider redirects the customer
to `GET /v1/connect/oauth/callback`, which consumes the HMAC-signed single-use
`state`, exchanges the code, writes the records with the access token
(discarded immediately unless the sign-in is kept as a
[managed sign-in grant](/docs/connect-flow/managed-connections#managed-sign-in-grants)),
and returns an HTML page that postMessages `{ type: "customdomain:oauth", payload }`
to the vetted `return_origin`. The connection is now `propagating`. `flow` says
which kind of sign-in the popup runs: `oauth2`, `vercel_integration` (the
CustomDomain™ integration install), `porkbun_handoff` (Porkbun's key approval),
`ovh_consent` or `mcp_oauth` (sign-in through the provider's MCP server,
switched on for Dynadot and Unstoppable Domains on the hosted service).

## Rail A: provider-hosted one-click setup

```bash
curl -X POST https://api.customdomain.ai/v1/connections/<ID>/domainconnect:start \
  -H "Authorization: Bearer <WIDGET_JWT>" \
  -H "Content-Type: application/json" \
  -d '{"return_origin":"https://app.acme.example"}'
# → { "apply_url": "https://<provider>/domainconnect/...", "service_id": "...", "dc_provider": "..." }
```

Redirect the customer to `apply_url`; their provider applies the signed template.
Both the synchronous redirect and the asynchronous variant are supported.
`service_id` may be omitted when a single/default template is loaded.

### Template variables

`vars` takes the template's caller-supplied variables, and only those. `email-full`
takes `mxHost`, `spfInclude`, `dkimSelector`, `dkimTarget` and `dmarcRua`
(`email-mx`, `email-spf`, `email-dkim` and `email-dmarc` take their part of that
set), `redirect` takes `redirectTarget`, the partner template `clickfunnels` takes
its partner's values, and every other template takes none. The edge address
(`edgeIp`, `edgeIp6`) always comes from the platform's own configuration, on every
path, a kept one-click authorization's re-apply included: naming one in `vars`,
or a variable the template does not take, answers `400 invalid_request`. Each value
must have its variable's shape, else `400 invalid_request` whose `details` names
it:

| Variable                             | Accepted value                                                                                                                                                                                              |
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mxHost`, `spfInclude`, `dkimTarget` | A fully qualified public hostname, such as `mx.example.com`: not an IP address, `localhost` or a name that resolves only inside a network (such as one ending in `.local`, `.internal`, `.lan` or `.corp`). |
| `dkimSelector`                       | One DNS label: up to 63 letters, digits, hyphens or underscores, with no dots.                                                                                                                              |
| `dmarcRua`                           | One email address at a public domain, such as `dmarc@example.com`. A `mailto:` prefix is accepted and dropped; `;`, `,` and other DMARC tags are refused.                                                   |
| `redirectTarget`                     | An `http` or `https` URL, or a bare host, whose host is a public hostname, with no user name or password, spaces, quotes or angle brackets.                                                                 |

On the synchronous redirect, the records of a template that takes caller values
(email, forwarding) are kept as awaiting the DNS provider until public DNS shows
them. Until then nothing else writes them: not a kept sign-in's re-apply, not a
zone CustomDomain™ hosts, and not the zone a nameserver move builds. The same
variable rules apply to `POST /v1/templates/{serviceId}/apply-url`, which answers
`404` for an unknown template.

The synchronous `apply_url` carries a signed `redirect_uri` back to
`GET /v1/connect/dc/return/{state}`. Every template we publish allows that
return since version 2 of the templates was merged into the public Domain
Connect template repository on 2026-09-29. After the customer approves or
cancels, the provider sends the browser to the return page, which records any
error the provider reports on the connection, starts the DNS check at once,
posts the same `{ type: "customdomain:oauth", payload }` message as the OAuth
callback to the `return_origin` you passed (it must be on the server's
allowlist), and closes the window. A return that reports success is never
taken as proof: only public DNS promotes the connection.

## Rail B: API token (BYO)

The customer supplies a **scoped** provider credential; it writes the records
and is discarded afterwards. To keep a key, connect it as a
[provider account](/docs/connect-flow/provider-accounts); in the console the
[remember box](/docs/dns/provider-setup#remembering-a-key-optional) starts
ticked and does that for you. In the console, multi-part credentials
are collected as separate labeled fields (GoDaddy: one Personal Access Token;
Namecheap: username and API key; Route 53: access key ID and secret access key,
or an access role instead) and joined into `credential.token` for you; see
[Provider setup](/docs/dns/provider-setup) for each provider's exact steps.
Calling the API directly:

```bash
curl -X POST https://api.customdomain.ai/v1/connections/<ID>/apply \
  -H "Authorization: Bearer <WIDGET_JWT>" \
  -H "Content-Type: application/json" \
  -d '{
    "provider": "cloudflare",
    "zone": "customer.com",
    "credential": { "token": "<scoped-provider-token>" },
    "records": [ { "type": "CNAME", "host": "app", "value": "edge.customdomain.ai", "ttl": 3600 } ]
  }'
```

`SPFM` records are merged into any existing SPF TXT at write time (never
clobbered); mark a record `essential: true` to fail the apply if it can't be
written.

## Bulk connect: more domains on one account

After a Rail B apply, one provider key can connect the **other*&#x2A; domains on that
same account in a single step. In the console this is the &#x2A;*"Connect more
domains"** panel: it lists the account's other zones and lets you **Select all**
or pick a subset, then connects them without re-entering the key.

Listing is supported for providers whose keys can read the whole account,
including Cloudflare, GoDaddy, Amazon Route 53, Namecheap, IONOS, NS1,
Scaleway, cPanel hosts, Porkbun, Hetzner, DigitalOcean, Name.com, Gandi,
DNSimple, OVHcloud, Vercel, Spaceship and Hostinger, and
for many other API providers (the
[full list](/docs/connect-flow/provider-accounts#which-providers-list-their-domains)
is with provider accounts). A provider that can't enumerate an account's
domains (or would need a broader key than the records do) answers
`list_not_supported` and degrades to connecting one domain at a time.

Two control-plane endpoints back it:

```bash
# 1. List the other zones on the account (credential passed once, not persisted)
curl -X POST https://api.customdomain.ai/v1/providers/cloudflare/zones:list \
  -H "Authorization: Bearer <WIDGET_JWT>" \
  -H "Content-Type: application/json" \
  -d '{"credential":{"token":"<scoped-provider-token>"}}'
# → { "provider": "cloudflare", "zones": ["acme.com", "beta.com"] }
# A provider that can't list returns 422 { "error": "list_not_supported", … }
# Needs a widget JWT or an API key; an empty credential.token is a 400.

# 2. Create connections for the selected domains (idempotent, up to 200),
#    optionally under a client's name
curl -X POST https://api.customdomain.ai/v1/connections:bulk \
  -H "Authorization: Bearer <API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"domains":["beta.com","gamma.com"],"end_user_ref":"Acme Co"}'
# → { "results": [
#      { "domain": "beta.com",  "connection_id": "con_…", "status": "connected" },
#      { "domain": "gamma.com", "connection_id": "con_…", "status": "already" }
#    ] }
```

Each result carries a &#x2A;*per-domain `status`**: `connected` (a new connection),
`already` (one already existed; the call is idempotent, so re-running is safe),
or `failed` (that domain errored; the batch continues and reports its `message`).
Each new connection then applies records and goes live on its own rail exactly
like a single connect.

The call takes a widget token with the `app` scope or your workspace's API key.
With a key, the connections are created on the key's application, or on
`application_id` when that names another application of the same workspace.

`client_label` is the name of the client the whole list is for, up to 200
characters, such as an agency's client. Characters that draw nothing are dropped,
and a name with nothing left stores no name. It is stored on every connection the
call creates, and the Domains page shows it, filters by it and includes it in an
export. A person can rename or clear it later in the domain's **Settings** tab,
or with `PATCH /v1/connections/{id}`
([change a domain's settings](/docs/api-reference/connections/updateConnection)).
`end_user_ref` is a different field: your own id for the customer the domains
belong to, up to 256 characters. It is set when a connection is created and is not
changed afterwards, because it ties a customer's widget token and saved access to
the connection. A domain that already exists keeps the name and the id it has and
comes back as `already`.

A workspace that has not subscribed yet connects its first ten domains, or the
number an operator set, without a card; the rest of a list come back `failed`
with the reason, one by one, until its trial starts.

## Secure sign-in

For a provider that offers no sign-in a third party can use, the customer signs in
to the provider in a temporary browser: `POST /v1/connections/{id}/browser:start`
starts the session, and the widget or the console opens it. A person is always
there, so an API key or an agent cannot start one. Where the provider allows it,
our software makes a lasting API key on the provider's own API page, and the
domain then goes on as an API key connection (Rail B), the key kept as a
[provider account](/docs/connect-flow/provider-accounts#keys-made-at-a-secure-sign-in);
otherwise the changes are made on the provider's DNS page once the customer
approves them. See [Secure sign-in](/docs/guides/secure-sign-in).

## DNS hosting

The sixth way writes no record into the provider's DNS. The domain's owner changes
its nameservers once, where the domain was bought, and CustomDomain™ hosts the zone
and writes every record in it: `POST /v1/connections/{id}/managed-dns`, and
`POST /v1/connections/{id}/nameservers:migrate` for the move. Your application
decides whether its customers are offered it. See
[Managed DNS](/docs/dns/managed-dns) and the
[nameserver migration guide](/docs/guides/nameserver-migration).

## Manual

If no automatic rail is available, show the records from
`GET /v1/connections/{id}/records` for the customer to add by hand. Nothing else
is required: the poller watches for them.

## Watch propagation → live

The control-plane's background poller resolves each desired record against
public DNS and, when they all match their intended values, flips the connection
to `live`. When the public resolver does not show a record yet (it may still
remember the name as missing, from a lookup before the record existed, or hold
the old value), the check asks the zone's own nameservers instead: every one of
them that answers must serve the record, so a provider still copying the change
between its nameservers is not counted as done. On that transition the edge issues the TLS certificate
on the next handshake and a `connection.live` [webhook](/docs/webhooks/overview)
fires (exactly once). Treat webhooks as the primary signal and **poll
`GET /v1/connections/{id}` as the fallback**: delivery is durable but can lag
(the outbox retries for up to six hours).

Timing, honestly:

* **A write is checked at once.** When a rail writes the records (a sign-in, an
  API key apply, a one-click return or an async callback), the connection is
  due on the poller's next pass, and the poller looks every five seconds. A
  record the provider serves right away is usually verified within seconds.
* **Checks back off with age.** Counted from when the connection was created
  (or last re-entered verification): every 5 seconds for the first 5 minutes,
  every 30 seconds until 15 minutes, every 2 minutes until an hour, then every
  15 minutes.
* A &#x2A;*`pending`** connection (records not yet seen) never fails on its own.
  After 72 hours it stays `pending` with `error_code: setup_incomplete` and is
  re-checked every six hours, forever. The moment the records appear it goes
  live.
* A &#x2A;*`propagating`** connection (a rail wrote the records) that never resolves
  within 24 hours becomes `failed` with `error_code: propagation_timeout` and
  fires `connection.failed` (the event carries `error_code` / `error_message`).
* **`failed` is not terminal.** `POST /v1/connections/{id}:recheck` revives it
  (fresh window, diagnosis cleared); re-`POST /v1/connections` for the same
  domain revives the newest failed row and returns `200`; and real traffic
  arriving at the edge for a failed host re-enters it into verification
  automatically. Only a manual disconnect removes a connection.

Where a rail can complete today on the hosted service: one-click setup
completes at NameSilo, Glauca Digital and Domain Chief (the providers measured
to serve our template catalog); GoDaddy, IONOS, Cloudflare and Vercel have not
onboarded it, so they are never offered one-click. Sign-in runs at Cloudflare,
DigitalOcean, DNSimple, Linode, Netlify, Vercel (the integration install) and
WordPress.com, and Porkbun's key approval runs too; each of these is kept,
encrypted, unless the person chooses one-time use. Sign in with Azure
DNS and the sign-ins through Dynadot's and Unstoppable Domains' MCP servers
run as well, and each of those is used once. Sign in with Google Cloud runs for
Google Cloud DNS. OVHcloud's consent and the other MCP server sign-ins are built
but not switched on yet. Secure sign-in runs at Namecheap,
GoDaddy, IONOS, Name.com, Squarespace, Hover, Spaceship and Hostinger, for the
workspaces it is switched on for.
Read `rails` on the pre-flight rather than memorizing this list; see
[Rail truth](/docs/concepts/ownership-and-setup-types#rail-truth-what-to-actually-tell-the-user).
