# API reference

> The Custom Domain API v1 reference. Register customer hostnames, read their DNS records and checks, re-check and delete them, with idempotency, pagination, errors and limits.

Source: https://customdomainapi.com/docs/api/

**Base URL:** `https://edge.customdomainapi.com/v1` on Free; on a paid or
self-hosted edge, `https://<your edge name>/v1`.

**Authentication:** every request sends your API key:

```
Authorization: Bearer cd_...
```

The application is derived from the key, never from a request field. Keys
are not accepted in query strings or cookies.

**Machine-readable contract:** the OpenAPI document is at
[`/v1/openapi.json`](https://edge.customdomainapi.com/v1/openapi.json) on
every edge, and in the repository as
[docs/openapi.json](https://github.com/sireto/custom-domain/blob/main/docs/openapi.json).

## Endpoints

| Method and path | Purpose | Success |
|---|---|---|
| `POST /v1/domains` | Register a hostname for a workspace. | `201`, or `200` on an idempotent replay |
| `GET /v1/domains` | List domains. Filters: `reference`, `status`, `include_deleted`. Pages: `limit` (1 to 200, default 50), `offset`. | `200` |
| `GET /v1/domains/{id}` | One domain with its current checks. | `200` |
| `POST /v1/domains/{id}/checks` | Re-run all checks now. | `202` |
| `DELETE /v1/domains/{id}` | Stop serving and delete. | `202` |
| `POST /v1/webhooks` | Subscribe to events ([Webhooks](/docs/webhooks/)). | `201` |
| `GET /v1/webhooks` | List subscriptions. | `200` |
| `POST /v1/webhooks/{id}/rotate` | New signing secret. | `200` |
| `DELETE /v1/webhooks/{id}` | Revoke a subscription. | `200` |

## Register a domain

```
POST /v1/domains
Authorization: Bearer cd_...
Idempotency-Key: signup-ws_8f3a1c
Content-Type: application/json

{"hostname": "forms.acme.com", "reference": "ws_8f3a1c", "metadata": {"plan": "pro"}}
```

- `hostname`: the customer's exact subdomain. It's normalized to lowercase
  punycode. Apex domains (`acme.com`), wildcards and IP addresses are
  refused.
- `reference`: your opaque workspace id (up to 255 characters). It is
  returned verbatim and delivered to your origin in the assertion.
- `metadata`: optional, up to 32 scalar values, echoed back.

Response `201`:

```json
{
  "id": "6f1c2d3e-4b5a-4c6d-8e9f-0a1b2c3d4e5f",
  "hostname": "forms.acme.com",
  "reference": "ws_8f3a1c",
  "status": "pending_dns",
  "dns_records": [
    {"name": "_custom-domain-challenge.forms.acme.com", "type": "TXT",
     "value": "custom-domain-verify=…", "purpose": "ownership", "help": "…"},
    {"name": "forms.acme.com", "type": "CNAME",
     "value": "edge.customdomainapi.com", "purpose": "routing", "help": "…"}
  ],
  "checks": [
    {"type": "ownership", "status": "pending", "error_code": null, "message": null,
     "observed_at": null, "next_check_at": null},
    {"type": "routing", "status": "pending"},
    {"type": "certificate", "status": "pending"},
    {"type": "origin", "status": "pending"}
  ],
  "metadata": {"plan": "pro"},
  "created_at": "2026-10-04T14:00:00Z",
  "updated_at": "2026-10-04T14:00:00Z",
  "deleted_at": null
}
```

Show both `dns_records` to your customer exactly as returned. Statuses and
checks are explained in [How it works](/docs/how-it-works/).

## Idempotency

Send `Idempotency-Key` (up to 255 characters, unique per application) on
`POST /v1/domains`, and retries are safe:

- the same key and body returns the first result with `200` and
  `Idempotent-Replayed: true`;
- the same key with a different body is `422 idempotency_key_reused`;
- a retry while the first is still running is
  `409 idempotency_request_in_progress`;
- keys expire after 24 hours.

## Errors

Every error has the same shape:

```json
{"error": {"code": "hostname_already_claimed", "message": "forms.acme.com is already claimed", "details": {}}}
```

| Status | `code` | When |
|---|---|---|
| 401 | `unauthorized` | Missing, unknown, revoked or expired key. |
| 403 | `application_suspended` | Your application is suspended. |
| 404 | `domain_not_found` | No such domain in your application. Another application's domains are never visible. |
| 409 | `hostname_already_claimed` | The hostname is live in this or another application. |
| 409 | `domain_limit_reached` | Your application is at its domain limit (25 on Free). Existing domains keep working. |
| 409 | `invalid_status_transition` | Not possible in the domain's status, such as re-checking a deleted domain. |
| 409 | `idempotency_request_in_progress` | The first request with this key hasn't finished. |
| 422 | `validation_error` | The body or query doesn't match the schema; `details.errors` lists where. |
| 422 | `apex_not_supported`, `wildcard_not_supported`, `ip_literal_not_supported`, `invalid_hostname`, `invalid_label`, `hostname_too_long`, `empty_hostname` | The hostname can't be registered. |
| 422 | `invalid_reference` | The reference is empty or longer than 255 characters. |
| 422 | `idempotency_key_reused` | The key was used with a different body. |
| 429 | `rate_limited` | Too many requests; wait `Retry-After` seconds. |

## Limits on Free

| Limit | Value |
|---|---|
| Customer hostnames | 25 |
| API keys | 1 (two during a rotation's 24-hour overlap) |
| API requests | 60 a minute per key |
| Proxied requests | 600 a minute and 100 a second, across all your hostnames; over either, visitors get `429` with `Retry-After` |
| Manual re-checks | 1 per domain a minute, 60 per application an hour |
| Fair use | 10 GB of responses served a month: above it we email you; nothing is switched off |

Paid plans have their own edge, with more domains and no proxied-request
limit of ours.

## SDK

The Python SDK wraps all of the above, with typed errors and safe retries:

```
pip install custom-domain-sdk
```

See the [SDK README](https://github.com/sireto/custom-domain/blob/main/sdk/README.md).
