custom domain.md
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.
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
- Open machinemail.dev/app/domains?create=1, or go to Domains and choose Add domain.
- Enter the Domain. A subdomain such as
agents.acme.devkeeps your main domain's mail untouched. - Leave Inbound mail on to receive mail at the domain. Turn it off for a send-only domain.
- Turn on Catch-all to accept mail for any address at the domain. *Ephemeral TTL (hours)* then sets how long those inboxes live after their last message.
0keeps them forever. - 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
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).
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.
{
"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):
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. 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:
curl https://api.machinemail.dev/v0/domains/dom_01m4fy8hmc0000fpjt8gvxtayy/domain-connect \
-H "Authorization: Bearer $MACHINEMAIL_API_KEY"
{
"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,MISSINGorINVALID. AnINVALIDrecord 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
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.
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
- Open machinemail.dev/app/inboxes?create=1, or go to Inboxes and choose Create inbox.
- Enter the Address, then pick your domain from the list after the
@. The list shows only verified domains. - Optionally, set a Display name. It is shown in the From header of mail the inbox sends.
- Choose Create inbox.
API
Pass domain when you create an inbox:
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.
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_enabledis 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. 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.
0keeps 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.
Troubleshooting
When verification fails, the domain's reason lists the problem records, and each INVALID record has its own reason showing what DNS returned:
{
"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. |
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.