CustomDomain docs
Getting started

Self-hosted quickstart

Run the stack locally with Docker Compose and connect a first domain end to end through the API.

This walks the API against a local stack. On the hosted service, start from Which path are you on? — the API, widget, console and MCP pages cover each path with hosted URLs.

0. Run the stack (local)

make up   # docker compose: Postgres + control-plane + edge

The local control-plane listens on http://localhost:8080. In production you talk to the hosted control-plane at https://api.customdomain.ai instead — the calls below are identical apart from the base URL.

1. Provision a tenant, app, and key

One call creates a tenant, its owner, a default application, and a long-lived API key. It's guarded by the provision secret (X-Provision-Secret; in local dev the management/provision secrets may be unset).

curl -X POST http://localhost:8080/v1/tenants:provision \
  -H "X-Provision-Secret: <PROVISION_SECRET>" \
  -H "Content-Type: application/json" \
  -d '{"tenant_name":"Acme","app_name":"Acme Sites","owner_email":"[email protected]"}'
# → { "tenant_id": "...", "application_id": "...", "api_key": "sk_...", "client_secret": "..." }

api_key and client_secret are shown once — store them. (You can also create tenants and applications individually via POST /v1/tenants and POST /v1/applications with the management key.)

2. Mint a widget token

Trade the app's client_secret for a short-lived widget JWT (server-side only):

curl -X POST http://localhost:8080/v1/tokens \
  -H "Content-Type: application/json" \
  -d '{"application_id":"<APP_ID>","client_secret":"<CLIENT_SECRET>","domain":"app.customer.com"}'
# → { "token": "eyJ...", "token_type": "Bearer", "expires_in": 3600 }

3. Detect the domain

curl -X POST http://localhost:8080/v1/domains:check \
  -H "Authorization: Bearer <WIDGET_JWT>" \
  -H "Content-Type: application/json" \
  -d '{"domain":"app.customer.com"}'

The response tells you which rails can run for this domain and why not when they can't (rails), the server's pick (recommended_rail), whether that pick needs nothing typed by the end user (end_user_automatic), capability flags, any record_conflicts, and a blocked_reason when the domain can't be connected at all. See Rail truth.

4. Create a connection

curl -X POST http://localhost:8080/v1/connections \
  -H "Authorization: Bearer <WIDGET_JWT>" \
  -H "Content-Type: application/json" \
  -d '{"domain":"app.customer.com"}'

The response carries the connection id (also the jobId), its status (pending), and the authoritative desired records — by default a CNAME app.customer.com → edge.customdomain.ai.

5. Apply the records

Pick a rail. The manual path needs nothing more than the customer adding the records above. To write them programmatically with a scoped provider token (Rail B):

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>"},
       "records":[{"type":"CNAME","host":"app","value":"edge.customdomain.ai"}]}'

The connection moves to propagating. (Rail C is oauth:start, Rail A is domainconnect:start — see Apply records & go live.)

6. Go live

The background poller (running automatically) promotes the connection to live once every desired record resolves. Watch it:

curl http://localhost:8080/v1/connections/<ID> -H "Authorization: Bearer <WIDGET_JWT>"

When status is live, the edge issues the TLS certificate on the next handshake and proxies https://app.customer.com to your origin. Register a webhook for connection.live to be notified.

Next steps

  • Embed the widget so customers do steps 3–6 themselves.
  • Register webhooks to react to go-live and drift.
  • Review the connect rails and what each needs to run in production.

On this page