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:
| 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 [email protected]. 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; 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
pendingconnection (records not yet seen) never fails on its own. After 72 hours it stayspendingwitherror_code: setup_incompleteand is re-checked every six hours, forever. The moment the records appear it goes live. - A
propagatingconnection (a rail wrote the records) that never resolves within 24 hours becomesfailedwitherror_code: propagation_timeoutand firesconnection.failed(the event carrieserror_code/error_message). failedis not terminal.POST /v1/connections/{id}:recheckrevives it (fresh window, diagnosis cleared); re-POST /v1/connectionsfor the same domain revives the newest failed row and returns200; 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.
Create a connection
POST /v1/connections creates a connection for a domain, replays safely if you retry, returns the DNS records to apply, and accepts a record set you supply.
End user notices
Give CustomDomain™ the end user's email, from your server or the widget: after a Move they get one email with the new DNS records, and a reconnect email when their provider's API key needs them, sent on your behalf.