CustomDomain Docs

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.

Once a connection exists, get its desired records into the customer's DNS by one of the six ways to connect: sign in with the provider (Rail C below), one-click setup (Rail A), secure sign-in, an API key (Rail B), add the records by hand, or DNS hosting. All of them move the connection pending → propagating → live.

Rail C: OAuth into the provider (flagship)

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), 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

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:

VariableAccepted value
mxHost, spfInclude, dkimTargetA 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).
dkimSelectorOne DNS label: up to 63 letters, digits, hyphens or underscores, with no dots.
dmarcRuaOne email address at a public domain, such as [email protected]. A mailto: prefix is accepted and dropped; ;, , and other DMARC tags are refused.
redirectTargetAn 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; in the console the remember box 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 for each provider's exact steps. Calling the API directly:

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 domains on that same account in a single step. In the console this is the "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 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:

# 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 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). 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; otherwise the changes are made on the provider's DNS page once the customer approves them. See 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 and the nameserver migration guide.

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

On this page