Domains
Verify sending domains with guided DNS, BYODKIM, and per-domain configuration.
Every email must be sent from a domain your team has verified — the API
rejects any other from address with 422. Domains are added and verified in
the dashboard.
Verification
Adding a domain registers it with SES and shows the DNS records to add:
- DKIM — a single
millionsend._domainkeyTXT record. MillionSend generates an RSA-2048 keypair per domain and hands the private key to SES (BYODKIM), so verification is one TXT record instead of three CNAMEs. The private key is never stored. - MAIL FROM — MX and SPF (TXT) records for the bounce subdomain.
On the cloud a team adds up to 10 domains an hour, or 100 on Scale. The count
restarts on every full hour (UTC); past it the API answers
429 rate_limit_exceeded with retry-after, and the dashboard says how many
minutes are left. A domain that isn't verified within 72 hours of being added
is deleted, so add each one when you're ready to publish its records.
Two verification signals are shown side by side:
- SES status — what SES reports. SES caches verification and can lag after you change records.
- Live DNS check — MillionSend resolves each record itself and reports Found / Missing / Mismatch immediately.
The API (GET /domains/{id}, POST /domains/{id}/verify, and the get_domain
/ verify_domain MCP tools) reports the same picture per record in records[].
status uses the Resend vocabulary: for DKIM and MAIL FROM it combines the live
check with SES — found in DNS but not yet confirmed by SES reads pending, a
different published value reads failed, no record reads not_started. Only
those rows gate sending. The DMARC row follows RFC 7489 discovery: it reads
verified when a policy covers the domain, including a parent domain's record
for a subdomain sender — then inherited_from names the _dmarc record that
answered and policy carries its p= value — and not_started when none does.
Every record also carries live (found, missing, mismatch or unknown,
what public DNS answers right now) and, when a row is not verified, a one-line
detail saying why.
Regions
A deployment provisions identities in the SES regions it serves — its
AWS_REGIONS, or the one region in AWS_REGION; MillionSend Cloud serves
sa-east-1 (São Paulo) today. The dashboard's add-domain form lists the
served regions, holds back one still in the SES sandbox while another has
production access, and defaults to the first production region. The API's
optional region field (the create_domain MCP tool included) accepts any
served region — the values its schema lists — and defaults to the first;
any other value is rejected with 422 naming the served regions.
Configuration sets, event topics and SES tenants are regional, so a domain
anywhere else would hand out DNS records but never send or report events.
A domain has exactly one region. To move it, delete it and add it again in the other region — its DNS records change, since SES identities are per region. On MillionSend Cloud a domain name another team holds is taken in every region.
New domain limit
On MillionSend Cloud a domain registered recently sends gradually while it
builds a reputation with mailbox providers. The age is the registered
domain's, so mail.acme.com counts as acme.com, and a name from a free
subdomain service, such as shop.eu.org, counts as its own domain. It is
read from public data when the domain is added, and the cap counts per UTC
day:
| Domain age | Emails per day |
|---|---|
| Under 24 hours | 100 |
| 1 to 7 days | 300 |
| 7 to 30 days | 2,000 |
| 30 days or older | your plan's limit only |
A domain that sends cleanly (few bounces, no spam complaints) moves up a step sooner. The cap applies on top of your plan, and a higher plan doesn't change it.
Your team's new domains share one daily volume, the highest of their caps, and each still stays within its own: adding another new domain doesn't add volume. Domains 30 days or older aren't part of it. When more than one of your domains is under this limit, the domain page says the volume is shared.
This limit is automatic and separate from the opt-in warm-up below. When both apply, a broadcast goes out within the lower of the two.
What you see
- Emails: each email the limit holds reads Waiting · daily limit, and When shows the time it goes out after, in your time zone. A banner above the list says how many emails wait for which domain, and that they go out on their own after 00:00 UTC.
- An email's page says it waits for the domain's daily limit, how many of today's emails went out, how many waiting emails are ahead of it, and when it should go out.
- Domains: a domain under the limit shows its daily volume and how many emails wait. Its tooltip has the schedule.
- The domain's page has a New domain limit section with what the domain sent today, what waits, how the limit grows and when it ends.
- Emails to your team's owners: Your domain is verified says the domain starts with a daily limit and when it ends. Your new domain reached today's limit says how many emails wait, at most once a day per domain. Turn it off under Domains in Settings → Notifications.
Mail that waits
Mail over the day's cap is never refused. It is accepted, waits, and goes out on its own after 00:00 UTC, oldest first, with transactional email ahead of broadcasts, so there's no need to resend it. Nothing that waits is lost while email content is kept: 30 days on Cloud, the instance's email retention when self-hosted.
Through the API, a send the limit holds answers with a warning:
{
"id": "4f1c9a2e-7b3d-4e8a-9c61-2d5f8e0b7a14",
"warning": {
"code": "new_domain_limit",
"message": "nova.example sent its 300 emails for today. This email goes out after 00:00 UTC.",
"sends_after": "2026-10-21T00:00:00.000Z"
}
}Each item of POST /emails/batch the limit holds carries the same
warning. GET /emails/{id} and GET /emails keep last_event: "queued",
as Resend clients expect, and add queued_reason: "new_domain_limit" with
sends_after, the earliest the email goes out. Only when a lot of mail is
already waiting does a send answer 429 daily_quota_exceeded (SMTP 452),
with a message naming the limit and a Retry-After header that counts to
00:00 UTC.
Ask for a higher limit
When the domain takes over mail that already has a sending history, such as a product moving from another email provider, choose Ask for a higher limit on the domain's page. Say what it sends, about how many emails a day, and where it goes out today. Owners and admins can ask, with one open request per domain. Mail keeps going out at the current pace meanwhile, and the team's owners and whoever asked get an email with the answer. You can also reply to either email above.
Warm-up
Mailbox providers trust a new sender a little more each day it behaves. Warm-up (beta) builds on that: while it is on for a domain, the domain's broadcasts go out under a daily volume that starts small and grows each day delivery stays healthy, most active contacts first. It is off by default and applies to one domain at a time. Your transactional email is never held.
Turn it on in the dashboard: on the domain's Warm-up tab, when you add the domain (answer "No, it's new to sending" to "Has this domain sent email before?"), or in the Send broadcast dialog, which offers it when a broadcast is far bigger than anything the domain has sent in a day.
When to use it
Turn it on for a new domain or subdomain before its first big send, or before a send far bigger than anything the domain has sent in a day.
Leave it off for a domain that already sends this volume through another provider. It keeps the reputation it has there. A domain that sends less elsewhere can still warm up from what it sends every day: raise the starting volume to match.
How it works
- Day 1 sends the starting volume: 1,500, or twice the domain's busiest day of the last 30 days when that is more. You can set it from 100 to 1,000,000.
- Each healthy day grows the volume 1.5×, up to the daily target, the most you plan to send in a day. The target starts at your number of subscribed contacts, at least 10,000, and you can set it from 1,000 to 10,000,000. The warm-up ends after a healthy day at the target.
- A day's volume goes out a 48th at a time, every 15 minutes, so it takes at least 12 hours. Days are UTC days, and each one starts only after the day before it is checked.
- Mail over the day's volume waits for the next day, in the same order, and shows as Waiting, for as long as email content is kept (see below).
- Only broadcasts wait. Email sent with
POST /emails, in a batch or through SMTP is never held. - A day that sends less than half its volume doesn't grow it. Days without a send don't count, and after 30 of them in a row the volume starts over.
Who goes first
Each day's volume goes to the contacts most likely to open it, ranked by the newest of these signals. You choose which ones count on the Warm-up tab.
- Their last open or click. A machine's prefetch never counts.
- The last transactional email they got from your team in the last 30 days.
- A contact property you pick, read as a date, such as a last login. It takes an ISO 8601 date or date-time, or Unix time in seconds or milliseconds between the years 2000 and 2100, so a count never reads as a date. The list shows your date properties first, then other properties where at least half the values are dates, read from your 20,000 most recently updated contacts.
A contact whose newest signal is 90 days old or more is inactive. By default the warm-up skips inactive contacts: they don't get the broadcast, and the broadcast page shows how many were skipped. Choose Send them last to put them at the end of the line instead. A contact with no signal at all is not inactive: it goes after the active ones.
The daily check
Early each UTC day, the day before is checked on five signals, read from the domain's broadcast mail of that day. Gmail is measured apart from other inboxes.
| Signal | What it reads | Line | Severe |
|---|---|---|---|
| Delivery delays | Gmail mail that Gmail delayed | under 5% | 20% or more |
| Opens, Gmail vs. others | Gmail's open rate as a share of other inboxes' | at least half | under a quarter |
| Hard bounces | Addresses that bounced for good | under 2% | 5% or more |
| Full or inactive mailboxes | Mailboxes that answered full or inactive | under 2% | 5% or more |
| Spam complaints | Complaints over mail to providers that report them | under 0.05% | 0.1% or more |
A signal past its line is missed. Gmail never tells senders about spam reports, so complaints come from the other providers, and your Gmail spam rate is in Google Postmaster Tools. Opens are read only while open tracking is on, and a machine's prefetch never counts as one. A signal with too little mail behind it (fewer than 100 emails, or 200 for complaints) reads Not enough mail and never counts against the day. It takes two complaints to miss the complaint line and three to make it severe.
After the check:
- Every signal within its line: the volume grows 1.5×.
- One missed signal: the volume holds.
- Two or more missed: the volume drops by 30%.
- A severe signal: the warm-up pauses and the team's owners get an email.
Pause, resume or turn off
- Pause warm-up stops the domain's broadcasts. Their mail waits in order. A severe signal pauses the warm-up the same way.
- Resume starts again from today at half the volume it had.
- Turn off ends the warm-up, and the mail that was waiting goes out at normal speed. A big day from a domain with little history is likely to land in spam, so the dashboard asks first.
- Once the warm-up is done, the domain's broadcasts go out at normal speed. Warm up again starts over from the starting volume.
Waiting mail keeps its place for as long as email content is kept, counted from when the broadcast was sent: 30 days on Cloud, the instance's email retention when self-hosted. Mail still waiting after that is not sent.
The team's owners get an email when a severe signal pauses the warm-up, with the signals that missed and how many emails are waiting, and another when the warm-up is done. Owners choose these under Deliverability in Settings → Notifications.
Should I switch to a new subdomain instead?
No. Mailbox providers judge reputation by the organizational domain
(example.com for news.example.com) and by the people you send to, so a
new subdomain doesn't start clean. Rotating domains looks like snowshoeing,
the spammer trick of spreading mail over many domains, and providers watch
for it. Warm up the domain you have.
Per-domain configuration
Once verified, a domain's Configuration tab controls:
- Click tracking — off by default. When on, links are rewritten to redirect
through your own tracking subdomain to record
email.clickedevents, then send the recipient on to the original URL. When off, your links ship untouched. - Open tracking — off by default. When on, a 1×1 pixel served from that same
tracking subdomain records
email.openedevents. Tracking is app-layer and runs on your own domain — never SES's link rewriting. Mail from a people inbox is never tracked, as from any mail client. - TLS mode —
opportunistic(default) orenforced, applied via the domain's SES configuration set.
Through the API and MCP, update_domain takes the same tracking settings, and
create_domain accepts them as optional extras so a tracked domain can be stood
up in one call (a Resend-shaped call that omits them is unchanged).
Tracking is served from the domain's own tracking subdomain: pass
tracking_subdomain (a label such as links) and the response's records[]
gains a Tracking CNAME whose status turns verified once it resolves. On
MillionSend Cloud, turning either kind on without a subdomain is refused with a
422. Until the CNAME resolves, links are not routed through it — Cloud ships
them clean, self-host falls back to the app host — and the domain shows as
Partial in the dashboard: verified for sending, tracking not yet live. On
Cloudflare the tracking record must stay DNS only (grey cloud): a proxied
CNAME answers with Cloudflare's addresses instead of the target, which the
records table reports as a mismatch, and TLS for the tracking host is served
by MillionSend. A tracking host redirects only the links of the team whose
domain holds it, and a domain holds its tracking host once verified, even if it
later loses verification. On MillionSend Cloud a host no domain holds redirects
nothing: after you change the tracking subdomain or delete the domain or the
team, links already sent through the old host stop redirecting, and they resume
only if the team adds the domain back with the same tracking subdomain and it
verifies. A self-hosted instance redirects every team's links from a host no
domain holds, so remove the tracking CNAME when you give a host up.
Open-rate accuracy
Open tracking injects a 1×1 transparent pixel with a unique reference into the
HTML body; a person loading that image records an email.opened event. It is
a directional signal, not an exact count.
Fetches a machine plausibly made are recorded as prefetched, not opened:
Apple Mail Privacy Protection downloads every image in the background whether
or not the message is read, Gmail pre-fetches while the inbox is already open,
and security scanners fetch the pixel within seconds of delivery. A prefetch
shows on the email's timeline and as its own line under the open rate, but it
never moves the status, never counts in the open rate, and never fires
email.opened (endpoints can opt in to email.prefetched). The timing rule's
window is OPEN_PREFETCH_WINDOW_SECONDS for self-hosters (default 10; 0
keeps only the user-agent rules). Links go through the same rules, plus two
of their own: a desktop Chrome that reports a build number no browser has sent
since Chrome's user-agent reduction, and two links of a message hit within a
quarter of a second, are a machine's whatever they call themselves. A click recorded before
the rest of its burst arrived is taken back — row, inferred open, counters,
status and any delivery not yet posted.
A click is also an open. A person cannot click a link in a message they never
rendered, so a click on an email with no open yet records the open as well,
stamped just before the click and marked reason: "click". For a recipient
whose images come through Apple Mail's cache this is the only open that can
ever be recorded, so Apple-heavy audiences read lower on opens than they would
elsewhere until they click.
Opens are under-counted when the recipient's client blocks images, when the email has no HTML part (a plain-text send carries no pixel), or when Gmail clips a message over ~102 KB and the recipient never expands it.
Clicks are the more reliable engagement signal. For purely transactional mail — receipts, password resets — consider leaving open tracking off: the pixel adds a tracking-shaped element some filters weigh against inbox placement, for a metric you can't fully trust anyway.
Receiving
A verified domain can also receive mail (beta). Every
domain response carries Resend's capabilities object: sending is
enabled (asking to disable it is a 422; only the
shared subdomain can read disabled), and receiving
is enabled or disabled. Switch receiving with Enable receiving on
the domain page, or send { "capabilities": { "receiving": "enabled" } }
(or "disabled") to PATCH /domains/{id}. Only a verified domain can turn
it on, which is why
POST /domains refuses "receiving": "enabled" with a 422: add the domain,
verify it, then switch receiving on. A domain name receives in one team only:
a name another team already receives on answers 409. On an instance that
doesn't receive mail at all, every domain reads disabled and turning it on
is refused with 422.
While receiving is on, records[] includes a Receiving row: an MX record
at the domain's own name pointing at mx.millionsend-dns.com (self-hosted,
the instance's RECEIVING_MX_HOST), priority 10.
{
"record": "Receiving",
"name": "inbound.example.com",
"type": "MX",
"ttl": "Auto",
"status": "verified",
"value": "mx.millionsend-dns.com",
"priority": 10,
"live": "found"
}The row is checked live like the others: verified once that host is among
the name's MX records with the lowest priority number, failed while another
MX record on the name has a lower number (detail lists what is published),
and not_started with no MX record at all. A lower-numbered MX record takes
the mail instead, which is why the MX belongs on a subdomain such as
inbound.example.com when the domain already receives mail elsewhere. The
row never gates sending.
receiving_catch_all says whether the domain takes mail at every address
(true) or only at its inboxes' addresses (false,
the default; other addresses are refused). Owners and admins switch it with
Catch-all on the domain page or PATCH /domains/{id} with
{ "receiving_catch_all": true }; on MillionSend Cloud it needs a paid plan.
See Which addresses receive.
Deleting a domain deletes its inboxes, with their threads and drafts, and
revokes their API keys.
Shared subdomain
On an instance with a shared inbox domain,
GET /domains ends with one more row: your team's subdomain of it, such as
acme.agents.example, where your shared inboxes get their addresses; a
team whose subdomain still waits for its name review has none yet. Every
row carries shared, and this is the one with shared: true. It reads
verified, in the instance's default region, with receiving on, catch-all
and tracking off, and two fields of its own:
sending_mode: what addresses on it may send now:open(start conversations),reply_only(reply inside their threads) orreceive_only(nothing goes out, andcapabilities.sendingreadsdisabled). An inbox whose address orfrom_namelooks like a well-known brand only replies, even when the mode isopen(see Shared domain).sending_mode_reason: why it is belowopen:plan(the team's plan) orinstance(the instance's settings);nullwhen open.
The row is read-only: GET /domains/{id} answers it with records: [],
since there is nothing to set up, and PATCH, POST /domains/{id}/verify
and DELETE answer 403 forbidden. It doesn't count toward your plan's
domain limit, and its id stays the same when a rename changes its name.
Though verified, it sends only from your inboxes' own addresses, and
POST /api-keys refuses its id as domain_id with 422 validation_error.
To create an inbox on it, send its id as domain_id to POST /inboxes.
The dashboard's Domains page shows it first, marked Shared.
API keys and domains
An API key can be scoped to a single domain; such a key can only send from
that domain (other domains return 403 restricted_api_key), and reads only
the mail received at it. Keys it creates
through the API are scoped to the same domain: an omitted domain_id inherits
it, and any other domain returns 403 restricted_api_key.
A key can be scoped to one inbox instead, which narrows it further: that inbox's mail, and sends from its address only.