# Custom domains

Serve a project from your own domain, such as `app.example.com`, as well as its `flatstack.app` address. Flatstack registers the domain with Cloudflare, which issues and renews its TLS certificate automatically. Traffic reaches your app only once the domain and its certificate are both active.

Each project can have as many domains as its plan allows: 1 on Hobby, 3 on Pro, 10 on Studio.

## Requests

| Request | Who | What it does |
| --- | --- | --- |
| `GET /v1/projects/{projectId}/domains` | Everyone in the account, tokens with `projects:read` | Lists the project's domains. Pending ones are checked with Cloudflare first, so status is current. |
| `POST /v1/projects/{projectId}/domains` | Owners, admins, tokens with `projects:write` | `{ "hostname", "environment"? }`. Attaches a domain to an environment (default `production`). Answers `201` with the DNS records to add. |
| `GET /v1/projects/{projectId}/domains/{domainId}` | Same as listing | One domain, always checked with Cloudflare. |
| `DELETE /v1/projects/{projectId}/domains/{domainId}` | Same as `POST` | Stops serving the domain and removes it from Cloudflare. Answers `204`. |

```json
{
  "id": "dom_…",
  "hostname": "app.example.com",
  "environment": "production",
  "status": "pending",
  "hostnameStatus": "pending",
  "sslStatus": "pending_validation",
  "dns": {
    "target": "domains.flatstack.app",
    "records": [
      { "type": "CNAME", "name": "app.example.com", "value": "domains.flatstack.app" },
      {
        "type": "TXT",
        "name": "_cf-custom-hostname.app.example.com",
        "value": "…",
        "optional": true,
        "note": "Add this before changing the CNAME to verify a domain that's serving another site, so it moves without downtime."
      }
    ]
  },
  "errors": [],
  "verifiedAt": null,
  "checkedAt": "2026-10-28T12:00:00.000Z",
  "createdAt": "2026-10-28T12:00:00.000Z"
}
```

## Setting up DNS

1. Add the domain with `POST /v1/projects/{projectId}/domains`.
2. At your DNS provider, add the first record from `dns.records`:
   - **Subdomains** (`app.example.com`): a `CNAME` to `domains.flatstack.app`.
   - **Apex domains** (`example.com`): an apex can't hold a CNAME, so use your provider's `ALIAS`, `ANAME` or CNAME flattening record pointing to `domains.flatstack.app`. Flatstack shows `ALIAS` for two-part names; for apexes like `example.co.uk`, follow the same rule.
3. Wait. Cloudflare validates the domain over HTTP once DNS points to it, then issues the certificate. This usually takes a few minutes, but DNS changes can take longer to spread.

If the domain already serves another site and you don't want downtime while you switch, add the optional `TXT` record first. Cloudflare can then verify you before the CNAME moves.

## Statuses

`status` combines Cloudflare's two statuses into one:

| `status` | Meaning |
| --- | --- |
| `pending` | Waiting for DNS, validation or the certificate. `hostnameStatus` and `sslStatus` show which step. |
| `active` | The domain and its certificate are active, and requests reach your app. |
| `failed` | Cloudflare stopped short: validation timed out, the domain was blocked, or DNS was later pointed elsewhere (`hostnameStatus: "moved"`). `errors` gives Cloudflare's reason. Fix DNS, then remove the domain and add it again. |

A domain that stops being active stops serving straight away. Failed domains don't count towards the plan limit.

## Rules

- Enter just the domain name: `app.example.com`, not `https://app.example.com/`. It's lower-cased and international names are stored in punycode. Wildcards, IP addresses, single-label names and Flatstack's own domains are `400 validation_failed`.
- Adding over the plan limit is `403 plan_limit_reached`, with `details: { "limit": "customDomainsPerProject", "current", "allowed" }`. Remove a domain or upgrade.
- A domain can belong to one project at a time. Adding one that's active, or recently added, elsewhere is `409 domain_taken`. A claim that hasn't verified after 7 days, or has failed, is released when someone else adds the domain, and the release is recorded in the original account's audit log.
- An environment that doesn't exist in the project is `404 not_found`, and so is another account's domain or project.
- Deleting a project stops serving its domains; restoring it serves them again. When a deleted project is purged, its domains are removed from Cloudflare.
- Adding and removing are in the audit log (`domain.added`, `domain.removed`). Status changes are recorded by the system as `domain.activated` and `domain.failed`.