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.
A connection is created against an application and authorized by a widget JWT (or an API key for server-side use). The only required field is the domain:
curl -X POST https://api.customdomain.ai/v1/connections \
-H "Authorization: Bearer <WIDGET_JWT>" \
-H "Content-Type: application/json" \
-d '{"domain":"app.customer.com"}'Optional body fields:
| Field | Meaning |
|---|---|
application_url | The Power origin the edge proxies this connection to. Validated before anything is written (absolute http or https, no private or loopback addresses). 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. |
www_redirect | Also write the www record and redirect it. Defaults to false. |
override_spf | Replace an existing SPF record instead of merging into it. Honoured only when the provider's spf_override_support capability is true; otherwise the existing record is merged. Defaults to false. |
validate_dmarc | Accepted and echoed back, not acted on yet. Defaults to false. |
validate_caa | Adds two apex CAA records that authorize Let's Encrypt (issue and issuewild) to the records the connection manages, next to any CAA records already there. Defaults to false. |
monitor | Drift monitoring. On unless you send false. |
batch_id | Groups the domains connected in one multi-domain session. |
end_user_ref | Your own id for the end user, shown as "Connected by" in the console. A widget token's end user must match. |
end_user_email | The end user's email address, so a Move emails them the new records. Wins over one given when the token was minted. Only an API key replaces an address the connection already has: a widget token's create keeps the stored one, a widget token bound to another end user stores none, and a delegated agent token that sends one is refused with 403. |
application_id | API key callers only: create on another application of the same workspace. A widget or agent token can only name its own. |
Unknown fields are rejected with 400. A workspace without an active or
trialing subscription gets 402 billing_required, and a plan at its domain
limit gets 402 quota_exceeded. A new workspace's
first ten domains are free.
Response
The response is the connection plus its authoritative desired record set:
{
"id": "con_…",
"application_id": "app_…",
"domain": "app.customer.com",
"provider_id": "cloudflare",
"setup_type": "automatic",
"status": "pending",
"created_at": "2026-07-07T12:00:00Z",
"records": [
{ "type": "CNAME", "host": "app.customer.com", "value": "edge.customdomain.ai", "ttl": 3600 }
]
}iddoubles as thejobIdused by the widget and webhooks.setup_type(automatic/manual/async/mcp) is chosen from provider detection; it steers which rail the widget offers.recordsis the same set you can re-fetch any time fromGET /v1/connections/{id}/records. Before a rail writes records, the default edgeCNAMEis synthesized server-side.- An API key reads
end_user_refandend_user_emailback. A delegated agent token's answer carries noend_user_ref, a replayed connection included, and it reads none on the connection list and the connection read either.
Supplying your own records
Some record sets cannot be derived from the domain. The motivating case is email
verification: Amazon SES mints three Easy-DKIM CNAME selectors per domain,
so no template can know them. Replace the connection's desired set with your own
before the widget opens:
curl -X PUT https://api.customdomain.ai/v1/connections/con_…/records \
-H "Authorization: Bearer cd_live_…" \
-H "Content-Type: application/json" \
-d '{"records":[
{"type":"MX","host":"@","value":"inbound-smtp.us-east-2.amazonaws.com","priority":10},
{"type":"TXT","host":"@","value":"v=spf1 include:amazonses.com ~all"},
{"type":"CNAME","host":"sel1._domainkey","value":"sel1.dkim.amazonses.com"}
]}'The supplied set then behaves exactly like ours everywhere: the OAuth rail writes
it with the customer's provider credential, the widget renders it for manual
setup, and the propagation poller verifies it before the connection goes live.
This is the way to give one customer records of their own. The SDK's
dnsRecords option cannot do it: it only carries DKIM records for the widget's
DKIM screen and ignores the rest, so the browser is never trusted with the
record list.
Three constraints follow from that trust boundary:
- API key only. A widget JWT lives in a browser; it may not choose the records written into a customer's zone.
- Hosts must resolve inside the connection's zone.
@,_dmarc, andsel1._domainkeyare anchored to the connection's domain; a foreign FQDN is rejected. - The Domain Connect rail refuses a supplied set (
400). That rail applies our published templates at the provider, so it cannot write records it did not author; use the OAuth rail or manual setup for these connections. - Secure sign-in refuses it too (
422 browser_unsupported_records), before the customer signs in. The customer gets the manual steps instead.
The replacement is atomic and rejected with 409 once records have been applied.
GET /v1/connections/{id}/records returns the resulting authoritative set, and
the connection carries records_source: "integrator".
Idempotency
POST /v1/connections is idempotent per application + domain. If the calling app
already has a pending, propagating or live connection for the domain, that
connection is returned with 200 (instead of 201), so it is safe to call on every
widget open. If the only existing connection is failed, it is revived
(status back to pending, diagnosis cleared, a fresh verification window) and
returned with 200 rather than a duplicate being created. The explicit retry is
POST /v1/connections/{id}:recheck (409 if a different active connection
exists for the domain). This is also what makes an abandoned setup resumable:
the console's Finish setup / Resume reopens the guided flow against the
existing connection at the right step. A widget token never gets another
customer's connection: when the existing connection (live, or failed and about to
be revived) names a different end_user_ref than the token's, or names one while
the token names none, the call answers 403 token_wrong_end_user and shows nothing
of it, and a failed connection is not revived. Mint the token with that customer's
end_user_ref. A connection that names no customer, your API key, an app scope
token and a delegated agent token are still answered with the existing connection.
A domain-bound widget JWT may only create a connection for the hostname it was
minted for. Hosting-platform subdomains
(*.vercel.app and the rest of the platform list) are refused with 422 platform_subdomain.
How the connect flow works
How a domain goes from create to apply to live, and the six ways in: provider sign-in, one-click setup, secure sign-in, an API key, the records or DNS hosting.
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.