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 + edgeThe 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.
MCP — drive it from an agent
The agent path — the hosted MCP server, its tools, and which of them can complete on the hosted service today.
How to set up a custom domain
Connect a custom domain to your app end to end — pick the right record, handle the apex, clear CAA and conflicting records, and know when it is actually live.