Custom Domain docs
API reference

API reference

Base URL, auth schemes, versioning, the error envelope, and the full generated operation reference.

Base URL (local): http://localhost:8080/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 three token types (see Authentication):

CredentialPrefix / formUsed for
Management keyopaqueoperator provisioning and cross-tenant reads
API keysk_live_… / sk_test_…long-lived server-to-server calls, tenant-scoped
Widget JWTJWTthe browser widget; minted server-side via POST /v1/tokens, scoped to one application (and optionally one domain)

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, unauthorized, not_found, service_unavailable, and internal_error. A few connect-flow failures carry a distinct, branchable code instead of a generic one so an integration can react specifically:

codeHTTPMeaning
quota_exceeded402The plan's monthly quota is used up. Only free is hard-capped, and only where BILLING_ENFORCE_QUOTA is on (the hosted service currently runs meter-only, so nothing is refused today) — no paid tier is ever refused; see Plans & quotas.
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 Apex 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

  • IdempotencyPOST /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 — 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