CustomDomain Docs

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:

EndpointReturns
GET /healthzLiveness + database readiness ({ status, service, env, db }); no tenant data.
GET /v1/configPublic 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.jsonThe OpenAPI document these reference pages are generated from.

Authentication

Every authenticated request sends Authorization: Bearer <token>. There are four token types (see Authentication):

CredentialPrefix / formUsed for
Management keyopaqueoperator provisioning and cross-tenant reads
API keycd_live_… / cd_test_… (keys created earlier start sk_live_… / sk_test_… and are still accepted)long-lived server-to-server calls, tenant-scoped
Widget JWTJWTthe 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 tokenES256 JWTa 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:

codeHTTPMeaning
billing_required402The 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_exceeded402The 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_suspended403CustomDomain™ 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_quota429The 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.
InvalidNameservers422The domain has no usable nameserver delegation, so its DNS provider can't be determined.
ProviderAuthenticationError422The end-user's DNS provider rejected the authorization or credential.
apex_not_supported422The 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/connections is idempotent per application + domain (an existing, non-failed connection is replayed with 200). POST /v1/registrar/purchase requires an Idempotency-Key header 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_unavailable when 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 --write

and 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.

On this page