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

Source: https://docs.customdomain.ai/docs/api-reference

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](#keeping-this-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](/docs/authentication/overview)):

| 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](/docs/authentication/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](/docs/agents/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:

```json
{ "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](/docs/billing/plans-and-quotas#what-a-refusal-looks-like).                                                                                                                                                                                                                        |
| `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](/docs/billing/plans-and-quotas#overage).                                                                                                                                                                  |
| `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](/docs/connect-flow/provider-accounts#checks-at-the-provider-have-an-hourly-limit).                                            |
| `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](/docs/dns/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](/docs/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](/docs/changelog#api-versioning-policy).
* **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:

```bash
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.
