CustomDomain Docs

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:

FieldMeaning
application_urlThe 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_redirectAlso write the www record and redirect it. Defaults to false.
override_spfReplace 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_dmarcAccepted and echoed back, not acted on yet. Defaults to false.
validate_caaAdds 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.
monitorDrift monitoring. On unless you send false.
batch_idGroups the domains connected in one multi-domain session.
end_user_refYour own id for the end user, shown as "Connected by" in the console. A widget token's end user must match.
end_user_emailThe 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_idAPI 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 }
  ]
}
  • id doubles as the jobId used by the widget and webhooks.
  • setup_type (automatic / manual / async / mcp) is chosen from provider detection; it steers which rail the widget offers.
  • records is the same set you can re-fetch any time from GET /v1/connections/{id}/records. Before a rail writes records, the default edge CNAME is synthesized server-side.
  • An API key reads end_user_ref and end_user_email back. A delegated agent token's answer carries no end_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, and sel1._domainkey are 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.

Next: apply the records and go live.

On this page