Custom Domain docs
Connect flow

Apply records & go live

The three automatic rails, the manual fallback, propagation to live, and cert issuance.

Once a connection exists, get its desired records into the customer's DNS via one of the four rails. All of them move the connection pending → propagating → live.

Rail C — OAuth into the provider (flagship)

curl -X POST http://localhost:8080/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?..." }

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 one-time access token (discarded immediately, never stored), and returns an HTML page that postMessages { type: "customdomain:oauth", payload } to the vetted return_origin. The connection is now propagating.

Rail A — provider-hosted one-click setup

curl -X POST http://localhost:8080/v1/connections/<ID>/domainconnect:start \
  -H "Authorization: Bearer <WIDGET_JWT>" \
  -H "Content-Type: application/json" \
  -d '{}'
# → { "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.

Rail B — API token (BYO)

The customer supplies a scoped provider credential; it writes the records once and is discarded afterwards by default (persisted only if the user opts in to remembering it). In the console, multi-part credentials are collected as separate labeled fields (GoDaddy: API key + secret; Namecheap: username + API key; Route 53: access key ID + secret access key) and joined into credential.token for you — see Provider setup for each provider's exact steps. Calling the API directly:

curl -X POST http://localhost:8080/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 Cloudflare, GoDaddy, Amazon Route 53, and Namecheap. Other providers can't enumerate an account's domains, so they degrade 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 http://localhost:8080/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", … }

# 2. Create connections for the selected domains (idempotent, up to 200)
curl -X POST http://localhost:8080/v1/connections:bulk \
  -H "Authorization: Bearer <API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"domains":["beta.com","gamma.com"]}'
# → { "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.

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

You don't poll the API for propagation — the control-plane's background poller (running automatically on a 1-minute interval) resolves each desired record against public DNS and, when they all match their intended values, flips the connection to live. On that transition the edge issues the TLS certificate on the next handshake and a connection.live webhook fires (exactly once). Read current state any time with GET /v1/connections/{id}.

A propagating connection whose records never appear within 24 hours becomes failed (and fires connection.failed) with error_code: "propagation_timeout". Manual pending connections get a much longer window — 72 hours, since a customer may take days to get to it — but they aren't unlimited: past that they also become failed, with error_code: "setup_incomplete".

On this page