Your own domain
Send and receive on a domain you own, so mail comes from your brand rather than ours.
By default your agent is hello@carlyemail.com. On your own domain it is
hello@yourcompany.com, and the sending reputation is yours rather than shared
with everyone else on ours.
Three ways through this, depending on who is doing it.
Tip
Use a subdomain. mail.yourcompany.com, not yourcompany.com. If sending
reputation ever goes wrong you can retire a subdomain; you cannot retire the
domain your company's own mail runs on. It also keeps our MX record off the apex,
where your existing mail provider's already is.
By hand
1. Add the domain
In the console, or:
npx carlyemail domain mail.yourcompany.com
You get back a list of records. There are five or six of them and they look alarming; they are ordinary, and each is explained below.
2. Download the zone file
Rather than transcribing them, take the file:
npx carlyemail domain mail.yourcompany.com --zone > carlyemail.zone
Most DNS providers have an Import or Upload zone file button. If yours does, use it and skip to step 4. Transcribing by hand is where this goes wrong.
3. Or paste them in, one at a time
Every provider calls the fields roughly the same thing. For each record you are filling in three boxes: Type, Name (sometimes "Host"), and Value (sometimes "Points to", "Content" or "Target").
Warning
The name field is usually relative. If we say the name is
bounce.mail.yourcompany.com and your provider already appends
yourcompany.com, you type bounce.mail. Getting this wrong produces
bounce.mail.yourcompany.com.yourcompany.com, which is the single most common
failure here. Some providers show you the final result as you type — check it.
What each one is for:
| Type | Name | Why |
|---|---|---|
| CNAME ×3 | <token>._domainkey.mail.yourcompany.com |
Signs your mail so it is not treated as forged |
| TXT | bounce.mail.yourcompany.com |
Says we are allowed to send as you |
| MX | bounce.mail.yourcompany.com |
Where bounces go |
| TXT | _dmarc.mail.yourcompany.com |
Tells receivers what to do with mail failing the checks |
| MX | mail.yourcompany.com |
Where mail to you is delivered |
The last one is the one to leave out if you only want to send.
4. Wait, then check
DNS takes anywhere from a minute to a few hours to spread.
npx carlyemail domain mail.yourcompany.com --check
Each record shows VALID or MISSING on its own, so a domain that is stuck
tells you which record is wrong rather than that something is.
5. Use it
Create inboxes with domain set to it, and mail sends from your name.
In code
Add the domain
TypeScript
const domain = await carly.domains.create({ domain: "mail.yourcompany.com" });
for (const record of domain.records) {
console.log(record.type, record.name, record.value, record.status);
}
Python
domain = carly.domains.create({"domain": "mail.yourcompany.com"})
for record in domain["records"]:
print(record["type"], record["name"], record["value"], record["status"])
cURL
curl -X POST https://api.carlyemail.com/v0/domains \
-H "Authorization: Bearer $CARLYEMAIL_API_KEY" \
-H 'content-type: application/json' \
-d '{"domain": "mail.yourcompany.com"}'
Response
{
"domain_id": "dom_00ms93lsavnm83rw519vzpgw",
"domain": "mail.yourcompany.com",
"status": "PENDING",
"records": [
{
"type": "CNAME",
"name": "abc123._domainkey.mail.yourcompany.com",
"value": "abc123.dkim.amazonses.com",
"status": "MISSING"
},
{
"type": "TXT",
"name": "bounce.mail.yourcompany.com",
"value": "v=spf1 include:amazonses.com ~all",
"status": "MISSING"
},
{
"type": "MX",
"name": "bounce.mail.yourcompany.com",
"value": "feedback-smtp.us-east-1.amazonses.com",
"priority": 10,
"status": "MISSING"
},
{
"type": "TXT",
"name": "_dmarc.mail.yourcompany.com",
"value": "v=DMARC1; p=quarantine; rua=mailto:dmarc@carlyemail.com",
"status": "MISSING"
},
{
"type": "MX",
"name": "mail.yourcompany.com",
"value": "inbound-smtp.us-east-1.amazonaws.com",
"priority": 10,
"status": "MISSING"
}
]
}
Publishing is yours to do — through your DNS provider's API, Terraform, or a
person. If you run your zone in Route 53 or Cloudflare, the records array maps
one to one onto their change APIs.
Then check
TypeScript
const checked = await carly.domains.verify(domain.domain_id);
console.log(checked.status); // PENDING until every record resolves
Python
checked = carly.domains.verify(domain["domain_id"])
print(checked["status"])
cURL
curl -X POST "https://api.carlyemail.com/v0/domains/$DOMAIN_ID/verify" \
-H "Authorization: Bearer $CARLYEMAIL_API_KEY"
Safe to call as often as you like. It re-reads the state rather than starting anything.
Create inboxes on it
TypeScript
await carly.inboxes.create({ username: "hello", domain: "mail.yourcompany.com" });
Python
carly.inboxes.create({"username": "hello", "domain": "mail.yourcompany.com"})
Inboxes on child domains
Set subdomains_enabled when one verified domain should cover addresses such as
agent@customer.mail.yourcompany.com:
TypeScript
const domain = await carly.domains.create({
domain: "mail.yourcompany.com",
subdomains_enabled: true,
});
await carly.inboxes.create({
username: "agent",
domain: "customer.mail.yourcompany.com",
});
The domain's records then includes one additional MX record on
*.mail.yourcompany.com. Publish it along with the others. Behind the API we
also add SES's separate .mail.yourcompany.com receipt condition; the wildcard
DNS record gets mail to SES, and the receipt condition makes SES accept it.
By hand, the equivalent is:
npx carlyemail domain mail.yourcompany.com --subdomains
Turning the setting off removes the wildcard record from records, stops new
child-domain inboxes, and removes the child-domain receipt condition. To avoid
silently breaking existing addresses, the API refuses to turn it off or delete
the parent domain until all child-domain inboxes have been deleted.
By an agent
An agent doing this for a customer needs three things: add the domain, hand the records to whoever controls DNS, and say precisely what is wrong while it waits.
Python
import time
def add_domain(name: str) -> dict:
domain = carly.domains.create({"domain": name})
return domain # hand domain["records"] to the customer
def wait_for(domain_id: str, minutes: int = 30) -> dict:
"""Poll until verified, or return the domain with its failing records."""
deadline = time.time() + minutes * 60
while time.time() < deadline:
domain = carly.domains.verify(domain_id)
if domain["status"] == "VERIFIED":
return domain
# Back off: DNS does not propagate faster for being asked.
time.sleep(60)
return domain
def whats_missing(domain: dict) -> list[str]:
return [
f"{r['type']} record on {r['name']} — expected {r['value']}"
for r in domain["records"]
if r["status"] != "VALID"
]
The per-record status is what makes this worth automating. whats_missing gives
an agent something specific to say — "the DMARC record on
_dmarc.mail.yourcompany.com is not published yet" — instead of "verification
failed", which no customer can act on.
Tip
Give the agent a key scoped to domain_create and domain_read and nothing
else. Adding a domain is not a reason to also be able to read mail.
Sending only
Leave out the MX record on the domain itself, publish the other four, and the
domain verifies for sending. Mail addressed to it will go wherever your existing
MX already points — which is what you want if yourcompany.com is a real mailbox
somebody reads.
How verification actually works
Two different mechanisms, because two different systems know the answer:
- DKIM and the bounce records are attested by the sending provider. Faster and more authoritative than a DNS lookup from our side.
- DMARC and the inbound MX we resolve ourselves, because the provider has no opinion on those two.
A lookup that fails for any reason — timeout, SERVFAIL, anything — is reported
as MISSING rather than VALID. A false "missing" makes you check a record that
was fine. A false "valid" hides the record that is breaking your mail.
When it will not verify
One CNAME missing, the rest fine. Almost always the relative-name problem in the warning above. Look up the record and see what it actually resolved to.
Everything MISSING after an hour. The records went into a different zone
than the one serving the domain. Common when a domain was transferred and two
providers both hold records for it.
DMARC MISSING but published. If you already have a DMARC record on the
parent domain, a subdomain inherits it and does not need its own — but we check
the exact name. Publish it on the subdomain too; two DMARC records at different
levels do not conflict.
Verified, but mail still is not arriving. Check the MX on the domain itself
rather than on bounce.. Those are different records doing different jobs, and
this is the pair people swap.
Where to go next
- Deliverability — warming up, and what wrecks a domain
- Suppression — bounces and complaints
- Pods — a domain per customer, if you are the one with customers