API reference
Base URL, the four token types and how to send them, API versioning, the error envelope and conventions, plus the full generated operation reference below.
Base URL: https://api.customdomain.ai/v1. All requests
and responses are JSON. The pages below this one are generated from the OpenAPI document
openapi-v1.yaml (see how this stays in sync); this
page is the only hand-written one in the section. The control-plane serves a
generated copy of that same document live at GET /v1/openapi.json; CI fails
if the two ever disagree.
Discovery endpoints
Three unauthenticated meta endpoints sit outside the generated reference:
| Endpoint | Returns |
|---|---|
GET /healthz | Liveness + database readiness ({ status, service, env, db }); no tenant data. |
GET /v1/config | Public capabilities: { service, env, version, api_version, products }. version is the running build string (APP_VERSION); products lists the wired engines (connect, secure, sell, power, monitor, mcp). |
GET /v1/openapi.json | The OpenAPI document these reference pages are generated from. |
Authentication
Every authenticated request sends Authorization: Bearer <token>. There are
four token types (see Authentication):
| Credential | Prefix / form | Used for |
|---|---|---|
| Management key | opaque | operator provisioning and cross-tenant reads |
| API key | cd_live_… / cd_test_… (keys created earlier start sk_live_… / sk_test_… and are still accepted) | long-lived server-to-server calls, tenant-scoped |
| Widget JWT | JWT | the browser widget; minted server-side via POST /v1/tokens, or for an account made in the console via its POST /api/widget-token (see Widget tokens); scoped to one application (and optionally one domain) |
| Agent token | ES256 JWT | a delegated AI agent's scoped, revocable access (domains:read, domains:connect, domains:disconnect, domains:purchase); issued by the agent OAuth flow and accepted only on the routes whose spec entry lists agentToken |
The one-call signup endpoint POST /v1/tenants:provision uses a dedicated
provision secret sent as X-Provision-Secret (or a Bearer token).
Error envelope
Every error is the same shape:
{ "code": "invalid_request", "title": "The request was invalid.", "details": "domain is required" }code is a stable machine string. The generic codes are invalid_request
(400), unauthorized (401), forbidden (403, the credential is valid but may
not do this), not_found (404), conflict (409, the request conflicts with the
resource's current state), rate_limited (429, slow down and retry shortly),
internal_error (500), and service_unavailable (503). A few connect-flow
failures carry a distinct, branchable code instead of a generic one so an
integration can react specifically:
code | HTTP | Meaning |
|---|---|---|
billing_required | 402 | The workspace has no active subscription or trial, so a new connection or a new credential (API key, application, rotated secret) is refused; existing ones keep working. A new workspace's first ten domains are the exception. See Plans & quotas. |
quota_exceeded | 402 | The plan's quota is used up (10 new domains in any 12 months on starter and the retired free). Only those two plans refuse at quota, and only where BILLING_ENFORCE_QUOTA is on (the hosted service runs meter only, so nothing is refused at quota today). No plan above Starter is ever refused; see Plans & quotas. |
workspace_suspended | 403 | CustomDomain™ support suspended the workspace. New connections, every connect step on a domain it already has (a key apply, a provider sign in, one-click setup, the Route 53 access role, the hosted zone and :reapply) and the start of a domain purchase are refused. Reads, rechecks and removals keep working, and connected domains keep their records. A connect-scoped widget token (your customer in the widget) gets 402 connections_unavailable instead. Only support can resume the workspace; nothing in the API does. |
reach_quota | 429 | The workspace has made as many reads at DNS providers, to find out what a key reaches, as it may this hour (60 an hour on the hosted service). Nothing was read, nothing changed and nothing was spent from the hour; Retry-After is the seconds until the hour ends. POST /v1/connections/{id}:use-saved-access answers it too, for a key that was not read for that reason. See provider accounts. |
InvalidNameservers | 422 | The domain has no usable nameserver delegation, so its DNS provider can't be determined. |
ProviderAuthenticationError | 422 | The end-user's DNS provider rejected the authorization or credential. |
apex_not_supported | 422 | The domain's provider has no ALIAS/ANAME/flattened-CNAME apex capability (see Root domains). |
title is human-readable; details is optional. Internal errors return a
generic title; the specifics are logged server-side under a request id, never
returned.
This table and the "Error envelope & codes" section on the Reference page are maintained separately; keep both in sync when a code changes.
Conventions
- Idempotency:
POST /v1/connectionsis idempotent per application + domain (an existing, non-failed connection is replayed with200).POST /v1/registrar/purchaserequires anIdempotency-Keyheader so a registration is never charged twice. - Scoping: list endpoints are tenant-scoped by the authenticated credential, never by a query parameter. Cross-tenant ids simply match nothing.
- Versioning: the surface is under
/v1, additive-only within a version; see the changelog. - Not-configured: optional engines (Sell, setup templates,
billing checkout, webhooks, Power, Secure/SSL) return
503 service_unavailablewhen the deployment hasn't wired them (SELL_PURCHASE_ENABLED,POWER_ENABLED,SECURE_ENABLED, and similar flags, all unset by default).
Keeping this in sync
openapi-v1.yaml is the single source of truth for the wire contract. Two
things are generated from it, and they are kept honest in different ways.
The served document. GET /v1/openapi.json does not read the YAML at
runtime: the control-plane embeds its own copy
(services/control-plane/internal/api/openapi.json) into the binary so the
endpoint needs no filesystem and no build step. That copy is generated from
openapi-v1.yaml, and CI (the openapi-sync job, running
infra/scripts/check-openapi-sync.sh) fails any change where the two are not
byte-identical after canonical rendering. So the served JSON and this reference
always describe the same contract, but they are two files, and the YAML is the
one to edit. After changing it, run:
infra/scripts/check-openapi-sync.sh --writeand commit the regenerated JSON alongside. Editing the JSON directly is a
mistake the gate will catch: a hand-patch there is invisible to npm run lint:openapi and to these pages. (That is not hypothetical: before the gate
existed, the two files had drifted in both directions at once, each holding
DomainCheck fields the other lacked.)
These reference pages. They are rendered from the same YAML by
npm run gen:api and the output is committed, so a spec change shows up as a
reviewable docs diff. That regeneration is a contributor step, not a CI
gate: re-run it in the same PR as any spec change (see AGENTS.md, "Docs
stay in sync"). next build will fail on a spec the renderer cannot load, but
it will not tell you that a committed page is one revision stale.
Note that nothing validates requests against this document: the control-plane hand-writes its request decoding (with unknown fields rejected). The spec is the contract of record, not the enforcement mechanism, which is exactly why the drift gate above matters.