# Custom domains (bring your own domain)

Every paid account gets a ready-to-use platform subdomain (something like
`a1b2c3.replylayer.net`) the moment it's ready to send. Mailboxes created
there work out of the box, and it never counts against your custom-domain
limit. Use it for testing, internal agents, or anything where branded
addressing doesn't matter. Trial (sandbox) accounts send from a temporary
shared trial address instead — see [Plans & limits](/docs/limits) — and get
their own platform subdomain the moment they upgrade.

**Bring your own domain (BYOD)** lets you send and receive from a subdomain you
control — `ai.acme.com`, `bot.example.io`, whatever fits your product. You keep
full authority over your DNS: you add a handful of records ReplyLayer gives you,
at whatever provider already hosts your zone (Route 53, Cloudflare, GoDaddy,
etc.). ReplyLayer verifies those records, then routes mail for the subdomain.

This guide covers the managed transport, where ReplyLayer handles outbound
delivery and inbound routing for you. A delegated domain can alternatively run
its own SMTP and IMAP servers
([self-hosted transport](/docs/guides/self-hosted-transport)) instead of the
managed path.

## What ReplyLayer does — and does not — do

ReplyLayer:

- Registers your subdomain with its delivery infrastructure.
- Returns the exact DNS records you need to add (SPF, DKIM, tracking, MX, a
  DMARC policy, and an ownership-proof record).
- Polls those records in the background and flips the domain to verified once
  they're all live.
- Routes inbound mail and signs outbound mail for the subdomain once verified.

ReplyLayer does **not**:

- **Create or manage DNS zones.** It never touches your root zone and never
  needs credentials to your DNS provider. You add the records yourself.
- **Modify your DNS after setup.** Health checks are read-only.
- **Remove your records when you delete the domain.** It can't — it doesn't
  control your DNS. Deleting a domain in ReplyLayer stops routing on its side;
  you remove the DNS records yourself when you're done.

A custom subdomain is a subdomain of a domain *you already own*. ReplyLayer does
not register root domains or issue TLS certificates for you.

## Who can manage domains

Domain management requires an **admin-role API key** or a **dashboard session**.
Mailbox-scoped agent keys are rejected — an agent can't add, verify, or delete a
domain. This keeps domain setup a deliberate, human-or-admin operation.

## The setup flow

### 1. Request the domain

Submit the subdomain you want to use:

```bash
curl -X POST https://api.replylayer.ai/v1/domains \
  -H "Authorization: Bearer $REPLYLAYER_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"domain": "ai.acme.com"}'
```

```ts
import { ReplyLayer } from '@replylayer/sdk';
const rl = new ReplyLayer({ apiKey: process.env.REPLYLAYER_ADMIN_KEY! });

const domain = await rl.domains.create({ domain: 'ai.acme.com' });
console.log(domain.verification_status, domain.dns_records_json);
```

```bash
rly domain create ai.acme.com
```

The **first** custom domain on an account goes through a brief review before its
DNS records are issued. When that happens the create response comes back with
`verification_status: "requested"` and a `message` telling you it's pending
review. Once approved, the domain advances to `pending_dns` and its
`dns_records_json` array appears — poll `GET /v1/domains/:id` (or watch the
Domains page in the dashboard) for the transition. Subsequent domains on the
same account skip the review and land in `pending_dns` immediately.

### 2. Add the DNS records

The response's `dns_records_json` array is the canonical list of records to
create. Each entry carries `type`, `name`, `value`, an optional `priority`
(for MX records), a `purpose` tag, and a per-record validation flag that flips
true as ReplyLayer confirms the record is live. **Copy `name` and `value`
verbatim** — the exact values are generated per domain.

| `purpose` | Record type | What it's for |
|---|---|---|
| `ownership_proof` | TXT | Proves you control the subdomain. ReplyLayer checks this record directly; verification can't complete without it. |
| `spf` | TXT | Authorizes ReplyLayer to send on the subdomain's behalf. |
| `dkim` | CNAME or TXT | Cryptographically signs your outbound mail. |
| `tracking` | CNAME | Serves open/click/unsubscribe tracking links from your subdomain. |
| `inbound_mx` | MX (two records) | Routes inbound mail for the subdomain to ReplyLayer. |
| `dmarc` | TXT | Publishes a DMARC policy for the subdomain. |

The DMARC record is generated for you and defaults to a non-disruptive
monitoring policy with relaxed alignment:

```
v=DMARC1; p=none; rua=mailto:dmarc-reports@mail.replylayer.ai; adkim=r; aspf=r;
```

`p=none` means "observe, don't reject" — nothing your subdomain sends is blocked
by the policy. Once you've validated delivery, you can tighten it to
`quarantine` or `reject` in your own DNS. If you already run a DMARC-management
tool, merge its `rua=` destination into this record rather than replacing it, and
keep the `mailto:dmarc-reports@mail.replylayer.ai` address in place.

**Tips that trip people up:**

- Some DNS providers treat the **Name** field as relative to your domain and
  append it automatically — silently publishing doubled names like
  `mail.example.com.example.com`. If every record shows invalid even though
  you've added them, look up the published names (`dig TXT
  _replylayer-verify.mail.example.com`) — if they're doubled, enter only the
  leading part (`mail`, `_replylayer-verify.mail`, …) without your domain.
- If your subdomain already has an SPF (`TXT`) record with different `include:`
  directives, **merge them** into one record. Two separate SPF records is an
  invalid configuration and will fail verification.
- DNS usually propagates within 5–60 minutes, though some providers take longer.

### 3. Verify

ReplyLayer polls your records automatically in the background. To check
immediately after adding them, trigger a check:

```bash
curl -X POST https://api.replylayer.ai/v1/domains/$DOMAIN_ID/verify \
  -H "Authorization: Bearer $REPLYLAYER_ADMIN_KEY"
```

```ts
const result = await rl.domains.verify(domainId);
console.log(result.verification_status, result.message);
```

```bash
rly domain verify <domain-id>
```

This runs the same logic as the background poller: it re-checks each record and
the ownership-proof record, and promotes the domain to `provider_verified`
inline if everything passes. If it's still waiting, the response `message` says
which gate is outstanding; inspect `dns_records_json` for the specific record
that hasn't validated yet. The verify call is rate-limited to **one per 60
seconds per domain** — a `429 RATE_LIMITED` response carries a `Retry-After`
header. The background poller keeps checking regardless, so you never *have* to
call this endpoint.

## Verification states

`verification_status` is the field to watch. The managed flow moves through:

| `verification_status` | What it means | What to do |
|---|---|---|
| `requested` | Submitted; first-time domains are in review before records are issued | Wait for approval; poll for `pending_dns` |
| `pending_dns` | Records issued; ReplyLayer is waiting for them to go live | Add every record from `dns_records_json`, then verify |
| `provider_verified` | All records valid + ownership proven; the domain is operational | Create mailboxes on it |
| `unhealthy` | Was operational, but a later health check found a record had drifted | Fix the drifted record; it recovers automatically |
| `failed` | Terminal — setup was abandoned/expired, or the domain stayed unhealthy too long. The name becomes re-claimable | Resubmit `POST /v1/domains` to start over |
| `rejected` | Terminal — the first-time review was declined | Contact support |

Once verified, ReplyLayer keeps checking your records periodically. If DNS drifts
later — an SPF edit, a changed DKIM target, an MX change — the domain moves to
`unhealthy`. Fix the record and it returns to `provider_verified` on the next
check. Persistent, unrecovered drift eventually lands the domain in `failed`.

`failed` and `rejected` are terminal. A `failed` domain's name is freed for
re-claiming, so you can simply submit it again to restart setup.

## Sending from a custom domain

New mailboxes pick up your account's **default** sending domain. The first custom
domain to reach `provider_verified` is auto-promoted to default if you don't
already have one. To choose the default explicitly:

```bash
curl -X PATCH https://api.replylayer.ai/v1/domains/$DOMAIN_ID/set-default \
  -H "Authorization: Bearer $REPLYLAYER_ADMIN_KEY"
```

```ts
await rl.domains.setDefault(domainId);
```

Only a verified, non-suspended domain can be set as the default; anything else
returns `400 DOMAIN_NOT_READY`.

A mailbox's address is fixed at creation — verifying a custom domain does **not**
move existing mailboxes onto it. To use branded addressing, create a fresh
mailbox on the custom domain after it's verified. Inbound mail to any mailbox on
the domain works as soon as its MX records resolve.

## Your platform subdomain: naming and readiness

The platform subdomain minted at upgrade starts with a random slug. Before the
first mailbox exists on it, you can swap the random slug for a **vanity slug**
(`acme-corp.replylayer.net`) from the dashboard. Slugs are governed by a
published policy — infrastructure terms, brand and provider names, common
phishing/impersonation terms, and visually-confusable lookalikes are reserved
(`422 SLUG_RESERVED`), and some categories require a manual support review
(`422 SLUG_REQUIRES_REVIEW`). Slugs are 3–32 characters, lowercase letters,
digits and hyphens. Once any mailbox exists on the subdomain, its name is
fixed — contact support to change it.

There is **no shared paid sending domain**: if you create a mailbox while your
platform subdomain is still verifying, the API answers
`409 DOMAIN_PROVISIONING_PENDING` (retry shortly — `rly mailbox create` waits
automatically, and the response carries a `Retry-After`). A rare setup failure
surfaces as `409 DOMAIN_PROVISIONING_FAILED` — retry later or contact support.
`GET /v1/domains` shows the subdomain's live `verification_status`.

Upgrading from the trial also **retires the temporary trial address**: the
sandbox mailbox is paused — its history stays readable and exportable, but it
no longer sends or receives. Your production identity is the platform
subdomain (or your own domain) going forward.

## Removing a domain

```bash
curl -X DELETE https://api.replylayer.ai/v1/domains/$DOMAIN_ID \
  -H "Authorization: Bearer $REPLYLAYER_ADMIN_KEY"
```

```ts
await rl.domains.delete(domainId);
```

Delete refuses while the domain still has **active mailboxes** (`409
ACTIVE_MAILBOXES`) — remove or reassign them first. Platform subdomains can't be
deleted via the API. After a successful delete, ReplyLayer stops routing for the
subdomain; **your DNS records are left in place** (ReplyLayer can't touch them),
so remove them at your provider when you're finished. Inbound mail to the
subdomain will bounce and sending from it will fail once routing is torn down.

## Limits and edge cases

- **How many custom domains you can have depends on your plan**, and sandbox
  accounts can't add custom domains at all. See [plans & limits](/docs/limits).
- If another account has already claimed the exact subdomain, `POST /v1/domains`
  returns `409 DOMAIN_TAKEN` with an `expires_at` hint for when the stale claim
  lapses. Contact support if it's genuinely your domain.
- A malformed subdomain is rejected up front with `400 INVALID_DOMAIN`.

For the full list of error codes and their shapes, see the
[error reference](/agents/errors).

---

## Related

- [Self-hosted transport](/docs/guides/self-hosted-transport) — run your own SMTP
  and IMAP servers for a delegated domain instead of the managed path.
- [Plans & limits](/docs/limits) — per-plan custom-domain counts.
- [Error reference](/agents/errors) — every error code, one catalog.
