# Custom domains

> Your own domains on an app: add, check, redirect, remove; prove a whole zone once.

An app answers on `https://<app>.rytiv.app` from the start. A custom domain is added to an app, proven by a DNS record, pointed at Rytiv, and gets its certificate from Rytiv. The answer of every domain route carries the DNS records to set. A zone proven once (`/v1/domains/zones`) covers every name below it without a record of its own. See the guide [Custom domains and TLS](https://docs.rytiv.com/guides/custom-domains.md).

## Endpoints

- [`POST /v1/apps/:name/domains`](#post-v1appsnamedomains): Adds a custom domain to the app and answers the DNS records to set
- [`GET /v1/apps/:name/domains`](#get-v1appsnamedomains): The app's custom domains with their status
- [`POST /v1/apps/:name/domains/:domain/check`](#post-v1appsnamedomainsdomaincheck): Checks the DNS records now instead of waiting for the next automatic check
- [`PUT /v1/apps/:name/domains/:domain/redirect`](#put-v1appsnamedomainsdomainredirect): Redirects this domain (`308`) to another hostname of the same app, or clears the redirect with `to: null`
- [`DELETE /v1/apps/:name/domains/:domain`](#delete-v1appsnamedomainsdomain): Removes the domain from the app
- [`POST /v1/domains/zones`](#post-v1domainszones): Claims a whole zone: proven once, every name below it is yours without a record of its own
- [`GET /v1/domains/zones`](#get-v1domainszones): Your zones
- [`GET /v1/domains/zones/:zone`](#get-v1domainszoneszone): One zone with its status and the records to set
- [`POST /v1/domains/zones/:zone/check`](#post-v1domainszoneszonecheck): Checks the zone's records now
- [`DELETE /v1/domains/zones/:zone`](#delete-v1domainszoneszone): Releases the zone

## POST /v1/apps/:name/domains

Adds a custom domain to the app and answers the DNS records to set.

**Auth:** API key with full access (a key with scopes cannot call this route). An agent token of class `sandbox` or `twin` may call it; class `production` is refused (`agent-token-production-readonly`).

### Path parameters

| Name | Description |
| --- | --- |
| `name` | The app's name. |

### Request body

JSON, the shape `AddDomainRequest` of the contract.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `domain` | string | required | The domain, for example `www.example.com`. Wildcards are not accepted. 1 to 253 characters. |

### Example request

```bash
curl -X POST "https://api.rytiv.app/v1/apps/<name>/domains" \
  -H "Authorization: Bearer $RYTIV_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"domain":"www.example.com"}'
```

### Response

`201` with a JSON body.

### Errors

| Status | Code | Meaning |
| --- | --- | --- |
| 400 | `bad-request` | The request is malformed or a field is out of range. `error` names every field; `issues` lists them. |
| 401 | `unauthenticated` | No API key, or one that does not work. |
| 403 | `token-scope` | The key's scopes do not cover this route. |
| 404 | `not-found` | Not found, or not yours. An object of another organization answers like one that does not exist. |
| 409 | `mail-domain` | The name is a sending domain and cannot become an app domain. |
| 409 | `shadows-default-address` | The domain is the default address of an app and cannot be a custom domain. |

## GET /v1/apps/:name/domains

The app's custom domains with their status.

**Auth:** API key with full access or the scope `read`. Every class of agent token may call it.

### Path parameters

| Name | Description |
| --- | --- |
| `name` | The app's name. |

### Example request

```bash
curl -X GET "https://api.rytiv.app/v1/apps/<name>/domains" \
  -H "Authorization: Bearer $RYTIV_TOKEN"
```

### Response

`200` with a JSON body.

### Errors

| Status | Code | Meaning |
| --- | --- | --- |
| 401 | `unauthenticated` | No API key, or one that does not work. |
| 403 | `token-scope` | The key's scopes do not cover this route. |
| 404 | `not-found` | Not found, or not yours. An object of another organization answers like one that does not exist. |

## POST /v1/apps/:name/domains/:domain/check

Checks the DNS records now instead of waiting for the next automatic check.

**Auth:** API key with full access (a key with scopes cannot call this route). An agent token of class `sandbox` or `twin` may call it; class `production` is refused (`agent-token-production-readonly`).

### Path parameters

| Name | Description |
| --- | --- |
| `name` | The app's name. |
| `domain` | The domain, as added. |

### Example request

```bash
curl -X POST "https://api.rytiv.app/v1/apps/<name>/domains/<domain>/check" \
  -H "Authorization: Bearer $RYTIV_TOKEN"
```

### Response

`200` with a JSON object carrying `queued` and the fields of the object the handler builds.

### Errors

| Status | Code | Meaning |
| --- | --- | --- |
| 401 | `unauthenticated` | No API key, or one that does not work. |
| 403 | `token-scope` | The key's scopes do not cover this route. |
| 404 | `not-found` | Not found, or not yours. An object of another organization answers like one that does not exist. |

## PUT /v1/apps/:name/domains/:domain/redirect

Redirects this domain (`308`) to another hostname of the same app, or clears the redirect with `to: null`.

**Auth:** API key with full access (a key with scopes cannot call this route). An agent token of class `sandbox` or `twin` may call it; class `production` is refused (`agent-token-production-readonly`).

### Path parameters

| Name | Description |
| --- | --- |
| `name` | The app's name. |
| `domain` | The domain, as added. |

### Request body

JSON, the shape `SetDomainRedirectRequest` of the contract.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `to` | string or null | required | Another hostname of the same app to redirect to, or `null` to clear the redirect. |

### Example request

```bash
curl -X PUT "https://api.rytiv.app/v1/apps/<name>/domains/<domain>/redirect" \
  -H "Authorization: Bearer $RYTIV_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"to":"www.example.com"}'
```

### Response

`200` with a JSON body.

### Errors

| Status | Code | Meaning |
| --- | --- | --- |
| 400 | `bad-request` | The request is malformed or a field is out of range. `error` names every field; `issues` lists them. |
| 401 | `unauthenticated` | No API key, or one that does not work. |
| 403 | `token-scope` | The key's scopes do not cover this route. |
| 404 | `not-found` | Not found, or not yours. An object of another organization answers like one that does not exist. |

## DELETE /v1/apps/:name/domains/:domain

Removes the domain from the app.

**Auth:** API key with full access (a key with scopes cannot call this route). An agent token of class `sandbox` or `twin` may call it; class `production` is refused (`agent-token-production-readonly`).

### Path parameters

| Name | Description |
| --- | --- |
| `name` | The app's name. |
| `domain` | The domain, as added. |

### Example request

```bash
curl -X DELETE "https://api.rytiv.app/v1/apps/<name>/domains/<domain>" \
  -H "Authorization: Bearer $RYTIV_TOKEN"
```

### Response

`204` with no body.

### Errors

| Status | Code | Meaning |
| --- | --- | --- |
| 401 | `unauthenticated` | No API key, or one that does not work. |
| 403 | `token-scope` | The key's scopes do not cover this route. |
| 404 | `not-found` | Not found, or not yours. An object of another organization answers like one that does not exist. |

## POST /v1/domains/zones

Claims a whole zone: proven once, every name below it is yours without a record of its own.

**Auth:** API key with full access (a key with scopes cannot call this route). An agent token of class `sandbox` or `twin` may call it; class `production` is refused (`agent-token-production-readonly`).

### Request body

JSON, the shape `AddZoneRequest` of the contract.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `zone` | string | required | The zone, for example `example.com`. 1 to 253 characters. |

### Example request

```bash
curl -X POST "https://api.rytiv.app/v1/domains/zones" \
  -H "Authorization: Bearer $RYTIV_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"zone":"example.com"}'
```

### Response

`200` or `201` with a JSON body.

### Errors

| Status | Code | Meaning |
| --- | --- | --- |
| 400 | `bad-request` | The request is malformed or a field is out of range. `error` names every field; `issues` lists them. |
| 401 | `unauthenticated` | No API key, or one that does not work. |
| 403 | `token-scope` | The key's scopes do not cover this route. |
| 503 | `zones-unavailable` |  |

## GET /v1/domains/zones

Your zones.

**Auth:** API key with full access or the scope `read`. Every class of agent token may call it.

### Example request

```bash
curl -X GET "https://api.rytiv.app/v1/domains/zones" \
  -H "Authorization: Bearer $RYTIV_TOKEN"
```

### Response

`200` with a list of zones, each:

```json
[
  {
    "zone": "example.com",
    "orgId": "<orgId>",
    "status": "pending_dns",
    "acmeLabel": "<acmeLabel>",
    "attempts": 0,
    "createdAt": "2026-10-21T09:00:00Z"
  }
]
```

| Field | Type | Present | Description |
| --- | --- | --- | --- |
| `zone` | string | always | The zone, for example `example.com`. |
| `orgId` | string | always | The organization that owns it. |
| `status` | `pending_dns` \| `verified` \| `cert_issued` \| `error` | always |  |
| `error` | string or null | if set |  |
| `acmeLabel` | string | always |  |
| `certNotAfter` | string (ISO 8601) | if set |  |
| `certIssuedAt` | string (ISO 8601) | if set |  |
| `verifiedAt` | string (ISO 8601) | if set |  |
| `lastCheckedAt` | string (ISO 8601) | if set |  |
| `attempts` | integer | always | 0 to 9007199254740991, default `0`. |
| `createdAt` | string (ISO 8601) | always | When it was created. |
| `dns` | object | if set |  |
| `dns.acme` | object | always |  |
| `dns.point` | object | always |  |

### Errors

| Status | Code | Meaning |
| --- | --- | --- |
| 401 | `unauthenticated` | No API key, or one that does not work. |
| 403 | `token-scope` | The key's scopes do not cover this route. |

## GET /v1/domains/zones/:zone

One zone with its status and the records to set.

**Auth:** API key with full access or the scope `read`. Every class of agent token may call it.

### Path parameters

| Name | Description |
| --- | --- |
| `zone` | The zone, as claimed. |

### Example request

```bash
curl -X GET "https://api.rytiv.app/v1/domains/zones/<zone>" \
  -H "Authorization: Bearer $RYTIV_TOKEN"
```

### Response

`200` with the zone:

```json
{
  "zone": "example.com",
  "orgId": "<orgId>",
  "status": "pending_dns",
  "acmeLabel": "<acmeLabel>",
  "attempts": 0,
  "createdAt": "2026-10-21T09:00:00Z"
}
```

| Field | Type | Present | Description |
| --- | --- | --- | --- |
| `zone` | string | always | The zone, for example `example.com`. |
| `orgId` | string | always | The organization that owns it. |
| `status` | `pending_dns` \| `verified` \| `cert_issued` \| `error` | always |  |
| `error` | string or null | if set |  |
| `acmeLabel` | string | always |  |
| `certNotAfter` | string (ISO 8601) | if set |  |
| `certIssuedAt` | string (ISO 8601) | if set |  |
| `verifiedAt` | string (ISO 8601) | if set |  |
| `lastCheckedAt` | string (ISO 8601) | if set |  |
| `attempts` | integer | always | 0 to 9007199254740991, default `0`. |
| `createdAt` | string (ISO 8601) | always | When it was created. |
| `dns` | object | if set |  |
| `dns.acme` | object | always |  |
| `dns.point` | object | always |  |

### Errors

| Status | Code | Meaning |
| --- | --- | --- |
| 401 | `unauthenticated` | No API key, or one that does not work. |
| 403 | `token-scope` | The key's scopes do not cover this route. |
| 404 | `not-found` | Not found, or not yours. An object of another organization answers like one that does not exist. |

## POST /v1/domains/zones/:zone/check

Checks the zone's records now.

**Auth:** API key with full access (a key with scopes cannot call this route). An agent token of class `sandbox` or `twin` may call it; class `production` is refused (`agent-token-production-readonly`).

### Path parameters

| Name | Description |
| --- | --- |
| `zone` | The zone, as claimed. |

### Example request

```bash
curl -X POST "https://api.rytiv.app/v1/domains/zones/<zone>/check" \
  -H "Authorization: Bearer $RYTIV_TOKEN"
```

### Response

`200` with a JSON object carrying `queued` and the fields of the object the handler builds.

### Errors

| Status | Code | Meaning |
| --- | --- | --- |
| 401 | `unauthenticated` | No API key, or one that does not work. |
| 403 | `token-scope` | The key's scopes do not cover this route. |
| 404 | `not-found` | Not found, or not yours. An object of another organization answers like one that does not exist. |

## DELETE /v1/domains/zones/:zone

Releases the zone.

**Auth:** API key with full access (a key with scopes cannot call this route). An agent token of class `sandbox` or `twin` may call it; class `production` is refused (`agent-token-production-readonly`).

### Path parameters

| Name | Description |
| --- | --- |
| `zone` | The zone, as claimed. |

### Example request

```bash
curl -X DELETE "https://api.rytiv.app/v1/domains/zones/<zone>" \
  -H "Authorization: Bearer $RYTIV_TOKEN"
```

### Response

`204` with no body.

### Errors

| Status | Code | Meaning |
| --- | --- | --- |
| 401 | `unauthenticated` | No API key, or one that does not work. |
| 403 | `token-scope` | The key's scopes do not cover this route. |
| 404 | `not-found` | Not found, or not yours. An object of another organization answers like one that does not exist. |
| 409 | `zone-in-use` |  |
