# Add a custom domain to a brand

> The brands.domains.add operation: POST /api/v1/brands/{id}/domains in the REST API and the brands_domains_add tool in the MCP server.

Adds a custom domain on which the brand's hosted checkout (`purpose: checkout`) or customer portal
(`purpose: portal`) is served, and attaches it to Ownrecord's hosting. The answer has the DNS records to create
at your DNS provider:

- a CNAME for a subdomain such as `pay.acme.com` (recommended), or A records for a domain at the apex;
- a TXT record `_ownrecord-challenge.{host}` with the value `ownrecord-verify={token}`, which proves the domain is
  yours. The token is new for every domain added;
- a TXT record of the hosting provider, when it needs its own proof because another account uses the domain.

The domain is `pending` until its DNS points at Ownrecord and the challenge record is in place; then
`brands.domains.verify` marks it `verified`. From then on, checkout links of the brand (the session `url`,
waitlist and invoice emails) use the domain, without the brand's slug: `https://pay.acme.com/cs_…`.

A brand has at most one checkout and one portal domain, and a host belongs to one brand only. The plan limits the
custom domains of the whole organization (`custom_domains.max`). Needs `brands:write`.

## At a glance

- **REST:** `POST /api/v1/brands/{id}/domains`
- **MCP tool:** `brands_domains_add`
- **Operation ID:** `brands.domains.add`
- **Scope:** Organization, or one of its legal entities or brands
- **Permission:** `brands:write`
- **Risk tier:** `write`
- **Dry run:** Supported (send `Ownrecord-Dry-Run: true`)
- **Idempotency:** `Idempotency-Key` required
- **Modes:** Test and live
- **Authentication:** API key with the `brands:write` scope
- **Since:** M9

## Request

### Path parameters

| Field | Type       | Description |
| ----- | ---------- | ----------- |
| `id`  | `brd_…` ID |             |

### Body

A JSON object (`Content-Type: application/json`):

| Field     | Type                        | Required | Description                                                                          |
| --------- | --------------------------- | -------- | ------------------------------------------------------------------------------------ |
| `host`    | string                      | Yes      | The host name, e.g. `pay.acme.com`: one you registered, without https\:// or a path. |
| `purpose` | one of `checkout`, `portal` | Yes      | `checkout` serves the brand's hosted checkout, `portal` its customer portal.         |

## Response

`201 Created` with a JSON object:

| Field                  | Type                               | Description                                                                                                                                                    |
| ---------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `object`               | `"brand_domain"`                   |                                                                                                                                                                |
| `host`                 | string                             | The host name, lower case (internationalised names in their xn-- form).                                                                                        |
| `purpose`              | one of `checkout`, `portal`        | `checkout` serves hosted checkout, `portal` the customer portal.                                                                                               |
| `brandId`              | string                             |                                                                                                                                                                |
| `status`               | one of `pending`, `verified`       | `verified` once the host points at Ownrecord and the challenge TXT record proves it's yours; `pending` until then.                                             |
| `verifiedAt`           | date-time or null                  |                                                                                                                                                                |
| `url`                  | string                             | The domain's base URL; hosted pages are served under it once it's verified.                                                                                    |
| `dnsRecords`           | array of object                    | The DNS records to create at the domain's DNS provider: routing, Ownrecord's challenge TXT record, and the hosting provider's TXT record when it asks for one. |
| `dnsRecords[].type`    | one of `A`, `AAAA`, `CNAME`, `TXT` |                                                                                                                                                                |
| `dnsRecords[].name`    | string                             |                                                                                                                                                                |
| `dnsRecords[].label`   | string                             |                                                                                                                                                                |
| `dnsRecords[].value`   | string                             |                                                                                                                                                                |
| `dnsRecords[].purpose` | one of `routing`, `ownership`      |                                                                                                                                                                |
| `check`                | object or null                     |                                                                                                                                                                |

## Examples

### A checkout domain

```http
POST /api/v1/brands/brd_01m4tx5nj8emwbyf7y9vcf47x4/domains
Authorization: Bearer or_test_…
Idempotency-Key: 3f1c9a52-7d0e-4b8f-9a61-0c2d5e8b7f14
Content-Type: application/json

{
  "host": "pay.acme-app.com",
  "purpose": "checkout"
}
```

Response (`201`):

```json
{
  "object": "brand_domain",
  "host": "pay.acme-app.com",
  "purpose": "checkout",
  "brandId": "brd_01m4tx5nj8emwbyf7y9vcf47x4",
  "status": "pending",
  "verifiedAt": null,
  "url": "https://pay.acme-app.com",
  "dnsRecords": [
    {
      "type": "CNAME",
      "name": "pay.acme-app.com",
      "label": "pay",
      "value": "cname.vercel-dns.com",
      "purpose": "routing"
    },
    {
      "type": "TXT",
      "name": "_ownrecord-challenge.pay.acme-app.com",
      "label": "_ownrecord-challenge.pay",
      "value": "ownrecord-verify=5e0c9a1b7d3f42e8a6b0c4d2e9f17a35",
      "purpose": "ownership"
    }
  ],
  "check": {
    "checkedAt": "2026-10-20T09:00:00.000Z",
    "ownershipVerified": true,
    "dnsConfigured": false,
    "challengeVerified": false,
    "problems": [
      {
        "code": "dns_not_configured",
        "message": "The host doesn't point at Ownrecord yet. Create the DNS records below; changes can take up to a few hours."
      },
      {
        "code": "challenge_missing",
        "message": "Ownrecord can't find its _ownrecord-challenge TXT record yet. Create it with the value below to prove the domain is yours."
      }
    ],
    "simulated": false
  }
}
```

### A portal domain whose hosting provider asks for its own TXT record too

The domain is used by another account at the hosting provider, which asks for proof of ownership as well.

```http
POST /api/v1/brands/brd_01m4tx5nj8emwbyf7y9vcf47x4/domains
Authorization: Bearer or_test_…
Idempotency-Key: 3f1c9a52-7d0e-4b8f-9a61-0c2d5e8b7f14
Content-Type: application/json

{
  "host": "billing.acme-app.com",
  "purpose": "portal"
}
```

Response (`201`):

```json
{
  "object": "brand_domain",
  "host": "billing.acme-app.com",
  "purpose": "portal",
  "brandId": "brd_01m4tx5nj8emwbyf7y9vcf47x4",
  "status": "pending",
  "verifiedAt": null,
  "url": "https://billing.acme-app.com",
  "dnsRecords": [
    {
      "type": "CNAME",
      "name": "billing.acme-app.com",
      "label": "billing",
      "value": "cname.vercel-dns.com",
      "purpose": "routing"
    },
    {
      "type": "TXT",
      "name": "_ownrecord-challenge.billing.acme-app.com",
      "label": "_ownrecord-challenge.billing",
      "value": "ownrecord-verify=c18d7e2f90ab4c3d8e5f6a7b1c2d3e4f",
      "purpose": "ownership"
    },
    {
      "type": "TXT",
      "name": "_vercel.acme-app.com",
      "label": "_vercel",
      "value": "vc-domain-verify=billing.acme-app.com,9b1f6c2e8d3a4f5b6c7d",
      "purpose": "ownership"
    }
  ],
  "check": {
    "checkedAt": "2026-10-20T09:00:00.000Z",
    "ownershipVerified": false,
    "dnsConfigured": false,
    "challengeVerified": false,
    "problems": [
      {
        "code": "ownership_unverified",
        "message": "The domain is used elsewhere on the hosting platform. Add the TXT record below to prove it's yours."
      },
      {
        "code": "dns_not_configured",
        "message": "The host doesn't point at Ownrecord yet. Create the DNS records below; changes can take up to a few hours."
      },
      {
        "code": "challenge_missing",
        "message": "Ownrecord can't find its _ownrecord-challenge TXT record yet. Create it with the value below to prove the domain is yours."
      }
    ],
    "simulated": false
  }
}
```

## Errors

| Code                                                                                                             | HTTP status | When                                                                                                                             | What to do                                                                                                             |
| ---------------------------------------------------------------------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| [`validation_failed`](https://ownrecord.io/docs/reference/errors.md#validation_failed)                           | 400         | A field is missing or malformed.                                                                                                 | Fix the fields listed in `details.issues` and send the request again.                                                  |
| [`domain_invalid`](https://ownrecord.io/docs/reference/errors.md#domain_invalid)                                 | 400         | The host isn't one a brand can use (a URL, an IP address, a reserved name; `details.reason`), or the domain provider refused it. | Enter one host name you registered, such as pay.acme.com: no https\://, path, port or wildcard, and not an IP address. |
| [`domain_taken`](https://ownrecord.io/docs/reference/errors.md#domain_taken)                                     | 409         | The host is attached to a brand already (this one or another).                                                                   | Use another domain, or remove it from the brand that uses it first.                                                    |
| [`domain_purpose_taken`](https://ownrecord.io/docs/reference/errors.md#domain_purpose_taken)                     | 409         | The brand already has a domain for this purpose.                                                                                 | Remove the brand's current domain for this purpose first, then add the new one.                                        |
| [`domain_unavailable`](https://ownrecord.io/docs/reference/errors.md#domain_unavailable)                         | 409         | Another project or account at the hosting provider holds the domain.                                                             | Remove the domain from the other project or account that uses it, or use another host name, then try again.            |
| [`entitlement_required`](https://ownrecord.io/docs/reference/errors.md#entitlement_required)                     | 403         | The organization's plan allows no more custom domains.                                                                           | Upgrade the organization's plan, or remove something to get back under the limit.                                      |
| [`domain_provider_error`](https://ownrecord.io/docs/reference/errors.md#domain_provider_error)                   | 502         | The domain provider failed; nothing was added.                                                                                   | Retry after a short wait. If it keeps failing, contact support with the request ID.                                    |
| [`domain_dns_unavailable`](https://ownrecord.io/docs/reference/errors.md#domain_dns_unavailable)                 | 502         | DNS didn't answer for the challenge record; nothing was added.                                                                   | Retry after a short wait. Nothing was changed.                                                                         |
| [`domain_provider_not_configured`](https://ownrecord.io/docs/reference/errors.md#domain_provider_not_configured) | 500         | Custom domains aren't set up on this deployment.                                                                                 | Contact support: the deployment's domain provider credentials are missing.                                             |
| [`upstream_timeout`](https://ownrecord.io/docs/reference/errors.md#upstream_timeout)                             | 502         | The domain provider or DNS didn't answer in time; nothing was added.                                                             | Retry the request with the same Idempotency-Key after a short wait.                                                    |
| [`resource_not_found`](https://ownrecord.io/docs/reference/errors.md#resource_not_found)                         | 404         | No brand with this ID is visible to the request.                                                                                 | Check the ID and that your credentials are scoped to the right organization, entity, brand and mode.                   |
| [`version_conflict`](https://ownrecord.io/docs/reference/errors.md#version_conflict)                             | 409         | The brand changed at the same time; retry.                                                                                       | Fetch the object again and retry the change.                                                                           |
| [`permission_denied`](https://ownrecord.io/docs/reference/errors.md#permission_denied)                           | 403         | You can't change this brand's settings.                                                                                          | Ask an organization owner or admin for a role or scope that allows this action.                                        |

It can also return these [common errors](https://ownrecord.io/docs/reference.md#common-errors): [`api_version_unsupported`](https://ownrecord.io/docs/reference/errors.md#api_version_unsupported), [`invalid_json`](https://ownrecord.io/docs/reference/errors.md#invalid_json), [`unsupported_media_type`](https://ownrecord.io/docs/reference/errors.md#unsupported_media_type), [`authentication_required`](https://ownrecord.io/docs/reference/errors.md#authentication_required), [`api_key_invalid`](https://ownrecord.io/docs/reference/errors.md#api_key_invalid), [`idempotency_key_required`](https://ownrecord.io/docs/reference/errors.md#idempotency_key_required), [`idempotency_key_reused`](https://ownrecord.io/docs/reference/errors.md#idempotency_key_reused), [`idempotency_in_progress`](https://ownrecord.io/docs/reference/errors.md#idempotency_in_progress), [`approval_required`](https://ownrecord.io/docs/reference/errors.md#approval_required), [`approval_rejected`](https://ownrecord.io/docs/reference/errors.md#approval_rejected), [`approval_expired`](https://ownrecord.io/docs/reference/errors.md#approval_expired), [`stale_approval`](https://ownrecord.io/docs/reference/errors.md#stale_approval), [`approval_policy_denied`](https://ownrecord.io/docs/reference/errors.md#approval_policy_denied), [`rate_limited`](https://ownrecord.io/docs/reference/errors.md#rate_limited), [`internal_error`](https://ownrecord.io/docs/reference/errors.md#internal_error).
