---
title: "Use a custom domain"
description: "Add your own domain to MachineMail: publish MX, SPF, DKIM and DMARC records by hand, from a zone file or with Domain Connect, verify it and turn on catch-all."
canonical: "https://machinemail.dev/docs/guides/custom-domain"
---

# Use a custom domain

> Add your own domain to MachineMail: publish MX, SPF, DKIM and DMARC records by hand, from a zone file or with Domain Connect, verify it and turn on catch-all.

Source: https://machinemail.dev/docs/guides/custom-domain

Inboxes on the shared `machinemail.dev` domain work with no setup. Add your own domain when you want agents to send from your brand, want unlimited addresses, or want catch-all addresses that exist without an API call.

The flow is: add the domain, publish its DNS records, verify, then create inboxes on it. Each step shows the dashboard first and the API second. Pick a tab once and the page remembers it.

## Add the domain

**Dashboard**

1. Open [machinemail.dev/app/domains?create=1](https://machinemail.dev/app/domains?create=1), or go to **Domains** and choose **Add domain**.
2. Enter the **Domain**. A subdomain such as `agents.acme.dev` keeps your main domain's mail untouched.
3. Leave **Inbound mail** on to receive mail at the domain. Turn it off for a send-only domain.
4. Turn on **Catch-all** to accept mail for any address at the domain. &#x2A;*Ephemeral TTL (hours)** then sets how long those inboxes live after their last message. `0` keeps them forever.
5. Choose **Add domain**. The domain's page opens with its DNS records.

The **Add domain** button is turned off when your plan's custom-domain limit is reached. Domains you already have keep working.

**API**

```bash title="Terminal"
curl https://api.machinemail.dev/v0/domains \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"domain": "agents.acme.dev", "catch_all": true, "catch_all_ttl_seconds": 86400}'
```

| Field                   | Default  | Meaning                                                                                               |
| ----------------------- | -------- | ----------------------------------------------------------------------------------------------------- |
| `domain`                | required | The hostname. A subdomain such as `agents.acme.dev` keeps agent mail apart from your people's mail.   |
| `inbound_enabled`       | `true`   | Receive mail at this domain. When `false`, the MX record is not required and the domain is send-only. |
| `catch_all`             | `false`  | Accept mail for any address at the domain, creating the inbox on first delivery.                      |
| `catch_all_ttl_seconds` | none     | Make catch-all inboxes ephemeral with this TTL.                                                       |
| `feedback_enabled`      | `false`  | AgentMail field, stored and returned.                                                                 |
| `client_id`             | none     | Idempotency key. A repeat with the same value returns the existing domain.                            |

The key needs `domain_create`.

Each domain can belong to one organization only. Adding one that is already registered fails with a conflict error (`409` from the API).

## Check for conflicting records

When you add a domain, MachineMail looks up the DNS records it already has and tells you which ones conflict with the records you are about to publish, and what to do about each. Read this before you change anything at your DNS host.

**Dashboard**

A message says so when the domain is added, and the domain's page lists each conflict above **DNS records**, one line each. Choose **What to do** on a line to see why it matters, the records DNS has now and your options. The list is refreshed every time the domain is checked, so a conflict disappears after you fix it and choose **Verify now**.

**API**

The domain has a `dns_conflicts` array. It is empty when nothing conflicts, and absent when the lookup did not complete (the domain is added all the same).

```json title="dns_conflicts (example)"
[
  {
    "kind": "mx",
    "type": "MX",
    "name": "acme.dev",
    "found": ["1 aspmx.l.google.com", "5 alt1.aspmx.l.google.com"],
    "provider": "Google Workspace",
    "title": "Existing MX records at Google Workspace",
    "summary": "Mail for acme.dev is delivered there today.",
    "why": "MX records decide where all mail for acme.dev is delivered: with the MachineMail MX record next to them, mail is split between the two, and with it in their place, mail stops reaching Google Workspace.",
    "options": [
      {
        "label": "Use a subdomain",
        "detail": "Add a name such as mail.acme.dev to MachineMail in place of acme.dev. It has MX records of its own, so the existing mailboxes keep working."
      },
      {
        "label": "Move all mail to MachineMail",
        "detail": "Delete the existing MX records and publish only the MachineMail one. Mail for acme.dev then stops reaching Google Workspace."
      },
      {
        "label": "Send only",
        "detail": "Turn inbound mail off for this domain and leave the MX records as they are. acme.dev can then send through MachineMail while its mail keeps arriving at Google Workspace."
      }
    ],
    "message": "acme.dev already has MX records for Google Workspace (1 aspmx.l.google.com, 5 alt1.aspmx.l.google.com). MX records decide where all mail for acme.dev is delivered: with the MachineMail MX record next to them, mail is split between the two, and with it in their place, mail stops reaching Google Workspace.",
    "resolution": "Pick one. Use a subdomain. Add a name such as mail.acme.dev to MachineMail in place of acme.dev. It has MX records of its own, so the existing mailboxes keep working. Move all mail to MachineMail. Delete the existing MX records and publish only the MachineMail one. Mail for acme.dev then stops reaching Google Workspace. Send only. Turn inbound mail off for this domain and leave the MX records as they are. acme.dev can then send through MachineMail while its mail keeps arriving at Google Workspace."
  }
]
```

`title` and `summary` are one short line each. `why` explains the conflict and `options` lists the ways to resolve it: one entry when there is a single thing to do, a choice when there are several. `message` and `resolution` say the same in two paragraphs, for a log line or a ticket. `kind`, `name` and `found` are for code. Verify Domain refreshes the list.

| `kind`  | The domain already has                                                                | What to do                                                                                                                                                                                         |
| ------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mx`    | MX records for other mail servers at a name where MachineMail needs its MX record.    | Add a subdomain such as `mail.acme.dev` instead, so the existing mailboxes keep working. Or remove the existing MX records to move all mail to MachineMail. Or turn inbound mail off to only send. |
| `spf`   | An SPF record at the name of the MachineMail SPF record, or more than one SPF record. | Keep one SPF record. Add the MachineMail `include:` to the existing record instead of publishing a second one; `suggested_value` is the merged record.                                             |
| `dmarc` | More than one DMARC record.                                                           | Keep one and delete the others. A single existing DMARC record is not a conflict: keep it, and do not add the MachineMail one.                                                                     |
| `cname` | A CNAME at a name where MachineMail needs an MX or TXT record.                        | Remove the CNAME, or add a name that is not in use. A name with a CNAME cannot have any other record.                                                                                              |

Conflicts never stop a domain from being added: you may be moving your mail on purpose. If you verify a domain while another provider's MX records are still next to the MachineMail one, the conflict stays listed and describes what is happening now: mail for the domain is shared between the two, with the same three ways out. Records that have nothing to do with mail, and other TXT records at the same name, are not conflicts.

## Read the DNS records

**Dashboard**

The domain's page lists the records under **DNS records**. Each row shows the **Type** (with the record's purpose under it), the **Name**, the **Value**, the **Prio** for MX records and the **Status**. The copy buttons next to each name and value copy the full text.

**API**

The response lists the records to publish. The values below are typical; always publish exactly what the API returns for your domain, because the DKIM key is unique per domain and the set can differ.

```json title="Response (201, trimmed)"
{
  "domain_id": "dom_01m4fy8hmc0000fpjt8gvxtayy",
  "domain": "agents.acme.dev",
  "status": "NOT_STARTED",
  "reason": "Publish the DNS records, then call POST /v0/domains/{domain_id}/verify",
  "inbound_enabled": true,
  "catch_all": true,
  "catch_all_ttl_seconds": 86400,
  "nameservers": ["ns23.domaincontrol.com", "ns24.domaincontrol.com"],
  "dns_provider": "godaddy",
  "dns_provider_url": "https://dcc.godaddy.com/control/acme.dev/dns",
  "records": [
    { "type": "TXT", "name": "_dmarc.agents.acme.dev", "value": "v=DMARC1; p=none", "status": "MISSING", "purpose": "dmarc", "required": false },
    { "type": "MX", "name": "agents.acme.dev", "value": "inbound-smtp.us-east-1.amazonaws.com", "priority": 10, "status": "MISSING", "purpose": "inbound", "required": true },
    { "type": "CNAME", "name": "4f3k2j..._domainkey.agents.acme.dev", "value": "4f3k2j....dkim.amazonses.com", "status": "MISSING", "purpose": "dkim", "required": true },
    { "type": "MX", "name": "mf.agents.acme.dev", "value": "feedback-smtp.us-east-1.amazonses.com", "priority": 10, "status": "MISSING", "purpose": "provider", "required": true },
    { "type": "TXT", "name": "mf.agents.acme.dev", "value": "v=spf1 include:amazonses.com ~all", "status": "MISSING", "purpose": "spf", "required": true }
  ]
}
```

MachineMail sends and receives through Amazon SES, and the records come from it, so publish them exactly as listed. There are three DKIM CNAMEs; the example above shows one.

| Purpose    | Record                                                                 | Why                                                                                                                                                        |
| ---------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `inbound`  | MX on the domain, priority 10                                          | Routes mail for the domain to MachineMail. Required only when `inbound_enabled` is true.                                                                   |
| `dkim`     | Three CNAMEs under `_domainkey.<domain>`, to `dkim.amazonses.com`      | The key for the signature on your outgoing mail.                                                                                                           |
| `spf`      | TXT `v=spf1 include:... ~all` on the sending subdomain (`mf.<domain>`) | Authorizes Amazon SES to send for the domain. If you already have an SPF record at that name, add the `include:` to it instead of publishing a second one. |
| `dmarc`    | TXT at `_dmarc.<domain>`                                               | A starting DMARC policy. Optional: any existing DMARC record counts.                                                                                       |
| `provider` | MX on the sending subdomain (`mf.<domain>`)                            | Amazon SES's MAIL FROM domain: bounces and complaints come back through it.                                                                                |

`required: true` records must all be valid before the domain verifies.

## Publish the records

MachineMail looks up the domain's nameservers when you add it and works out your DNS host: Cloudflare, Route 53, Google Cloud DNS, Vercel, Namecheap, GoDaddy, DigitalOcean, Hetzner, Porkbun, or another host.

**Dashboard**

The **DNS host** card above the records names your DNS host and its nameservers. You have two ways to get the records in.

**By hand.** When MachineMail knows your host's DNS console, the card has a button named after it, such as **Open in Cloudflare** or **Open in GoDaddy**. It opens the console in a new tab. Create each record there, copying the name and value from the table.

**Zone file.** Choose **Zone file** at the top of the page. It downloads a BIND zone file with the records. Import it if your DNS host supports imports (Cloudflare does).

**API**

The domain reports the DNS host in `dns_provider` (`cloudflare`, `route53`, `google`, `vercel`, `namecheap`, `godaddy`, `digitalocean`, `hetzner`, `porkbun` or `other`). When the host is known, `dns_provider_url` is a deep link to its DNS console.

**By hand.** Open `dns_provider_url` and create each record.

**Zone file.** Download a BIND zone file with the records and import it, if your DNS host supports imports (Cloudflare does):

```bash title="Terminal"
curl -o agents.acme.dev.zone https://api.machinemail.dev/v0/domains/dom_01m4fy8hmc0000fpjt8gvxtayy/zone-file \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY"
```

### One-click setup (Domain Connect)

> **Warning:** Domain Connect is not available yet. Each DNS host has to accept the MachineMail template before the one-click flow works, and that onboarding, including with Cloudflare, is still in progress. Until
> then, publish the records by hand or with the zone file.

Some DNS hosts support [Domain Connect](https://www.domainconnect.org). Once it is live for yours, the **DNS host** card also shows **Add records at** followed by your host's name. It opens your host's approval screen with the MachineMail records filled in. You approve there, and MachineMail checks DNS when you come back.

To check Domain Connect from the API:

```bash title="Terminal"
curl https://api.machinemail.dev/v0/domains/dom_01m4fy8hmc0000fpjt8gvxtayy/domain-connect \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY"
```

```json title="Response (today, for a Cloudflare zone)"
{
  "supported": false,
  "provider_id": "cloudflare.com",
  "provider_name": "Cloudflare",
  "url_sync_ux": "https://dash.cloudflare.com/domainconnect",
  "reason": "template_not_supported",
  "checked_at": "2026-10-09T09:10:00.000Z"
}
```

When `supported` is true the response also has `apply_url`, a signed link to your DNS host. Open it in a browser (as a popup of `width` by `height` pixels when those are set). It only adds the records that are still missing: the MX if inbound is enabled, SPF and DKIM, and DMARC only when the domain has no DMARC record at all. When `supported` is false, `reason` says why:

| `reason`                  | Meaning                                                                                         |
| ------------------------- | ----------------------------------------------------------------------------------------------- |
| `no_domain_connect`       | Your DNS host does not support Domain Connect.                                                  |
| `template_not_supported`  | Your DNS host supports it but has not accepted the MachineMail template yet.                    |
| `no_sync_flow`            | Your DNS host only offers a flow MachineMail does not use.                                      |
| `records_not_templatable` | This domain's records do not fit the template, for example because it needs `provider` records. |
| `already_configured`      | Nothing is left to add (`supported` is true, there is no `apply_url`).                          |
| `provider_error`          | The DNS host did not answer. Retried after 10 minutes.                                          |
| `not_configured`          | Domain Connect is not available yet.                                                            |

Every domain object also carries a short `domain_connect` summary (`supported`, `provider_name`, `apply_url`) from the last check. Discovery results are cached for 6 hours and refreshed on every verify.

## Verify

DNS changes can take minutes to propagate. Then run the checks.

**Dashboard**

Choose **Verify now** at the top of the domain's page. A message tells you how many records are valid, or that the domain is verified.

- The **Status** column shows each record as `VALID`, `MISSING` or `INVALID`. An `INVALID` record shows what DNS returned, in red under its value.
- The header shows the domain's status and when it was last checked. Under the domain name you see what is still wrong, or **Verified** and the date.

**API**

```bash title="Terminal"
curl -X POST https://api.machinemail.dev/v0/domains/dom_01m4fy8hmc0000fpjt8gvxtayy/verify \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY"
```

The response is the domain with a fresh `status` on each record (`MISSING`, `INVALID` or `VALID`) and an overall `status`. Verify needs the `domain_update` permission.

The domain's overall status:

| Domain `status` | Meaning                                               |
| --------------- | ----------------------------------------------------- |
| `NOT_STARTED`   | Never verified.                                       |
| `PENDING`       | Some required records are still missing.              |
| `INVALID`       | A required record exists with the wrong value.        |
| `VERIFIED`      | Every required record is valid. `verified_at` is set. |

Verify at least once. After that, MachineMail re-checks `PENDING` and `INVALID` domains in the background about every 5 minutes for 7 days after the domain was added, so a slow DNS change is picked up without polling. A domain in `NOT_STARTED` is not re-checked on its own.

If a domain is still `NOT_STARTED` 48 hours after you add it, MachineMail emails your organization's owners and admins its DNS records, with a link to this guide.

Verify within 14 days. A domain that is never verified is removed 14 days after it was added, so a domain added by someone who does not own it cannot keep its owner from adding it. The owners and admins of your organization get an email at least 3 days before, with the date. The inboxes on the domain are removed with it, with the mail in them. A domain that was verified once is never removed this way, even if its records stop checking out later.

When the domain becomes verified, MachineMail emits `domain.verified` with the domain in `data.domain`. Subscribe a webhook to it if you automate onboarding.

## Create inboxes on the domain

**Dashboard**

1. Open [machinemail.dev/app/inboxes?create=1](https://machinemail.dev/app/inboxes?create=1), or go to **Inboxes** and choose **Create inbox**.
2. Enter the **Address**, then pick your domain from the list after the `@`. The list shows only verified domains.
3. Optionally, set a **Display name**. It is shown in the From header of mail the inbox sends.
4. Choose **Create inbox**.

**API**

Pass `domain` when you create an inbox:

```bash title="Terminal"
curl https://api.machinemail.dev/v0/inboxes \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"username": "billing", "domain": "agents.acme.dev", "display_name": "Acme billing agent"}'
```

Inboxes on your own domains do not count toward the per-organization cap on the shared domain.

> **Note:** The API lets you create inboxes on a domain before it verifies, but mail will not arrive until the MX record is live, and receivers are likely to reject or junk what you send until SPF and DKIM
> pass. Verify first.

## Catch-all addresses

With `catch_all: true`, mail to any address at the domain is accepted, and the inbox is created on first delivery. That needs all of:

- the domain is `VERIFIED`,
- `inbound_enabled` is true,
- the local part is a valid username: lowercase letters, digits and `. _ + -`, 1 to 64 characters, starting and ending with a letter or digit.

Catch-all inboxes have `created_by: "catch_all"` and emit `inbox.created` like any other. If the domain has `catch_all_ttl_seconds`, they are ephemeral: they expire that long after the last message arrives and are then deleted with their mail. Inbox IDs are lowercased, so `Signup+GitHub@agents.acme.dev` lands in `signup+github@agents.acme.dev`.

To change these settings later:

**Dashboard**

Open the domain from [machinemail.dev/app/domains](https://machinemail.dev/app/domains). The **Receiving** panel has three settings:

- **Inbound mail**: accept mail for the domain. Turning it off also turns catch-all off.
- **Catch-all**: create an inbox on first delivery to any address. It needs **Inbound mail** on.
- **Catch-all TTL**: how many hours catch-all inboxes live after their last message. Enter the hours and choose **Save**. `0` keeps them.

**API**

Use `PATCH /v0/domains/{domain_id}`. It accepts `catch_all`, `catch_all_ttl_seconds` (`null` to clear it), `inbound_enabled` and `feedback_enabled`.

> **Warning:** A catch-all domain accepts mail for every address, including guesses and spam traps. Every message still gets a trust report, but use a dedicated subdomain for catch-all rather than your main
> domain.

## Troubleshooting

When verification fails, the domain's `reason` lists the problem records, and each `INVALID` record has its own `reason` showing what DNS returned:

```json title="Record (example)"
{
  "type": "TXT",
  "name": "mf.agents.acme.dev",
  "value": "v=spf1 include:amazonses.com ~all",
  "status": "INVALID",
  "purpose": "spf",
  "required": true,
  "reason": "Found: v=spf1 include:_spf.google.com ~all"
}
```

| Symptom              | Fix                                                                                                                                                                                       |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| SPF `INVALID`        | You have an SPF record at that name without the include from the API (for example `include:amazonses.com`). Add it; do not publish two SPF records.                                       |
| MX `INVALID`         | Another MX record answers for the name. For a subdomain like `agents.acme.dev`, put the MX on the subdomain, not on `acme.dev`. `dns_conflicts` lists the other records and your options. |
| DKIM `MISSING`       | Some DNS consoles append the zone to the name. In a zone for `acme.dev`, enter `<selector>._domainkey.agents`, not the full name.                                                         |
| DKIM `INVALID`       | The CNAME points somewhere else, or your DNS host proxies it. Point it exactly at the value from the API and turn proxying off for the three CNAMEs.                                      |
| Everything `MISSING` | DNS has not propagated yet, or the records are in a different zone than the nameservers in `nameservers`.                                                                                 |

To remove a domain, open it in the dashboard. Under **Danger zone**, choose the delete button, which names the domain (for example **Delete agents.acme.dev**), and type `DELETE` to confirm. From the API, call `DELETE /v0/domains/{domain_id}`. Sending and receiving on the domain stop at once. Inboxes on it stay until you delete them. Remove the DNS records afterwards.

## Related

- [Domains](https://machinemail.dev/docs/concepts/domains)
- [Inboxes](https://machinemail.dev/docs/concepts/inboxes)
- [OTP codes and sign-up flows](https://machinemail.dev/docs/guides/otp-and-signups)
