MillionSend Docs

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._domainkey TXT 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 ageEmails per day
Under 24 hours100
1 to 7 days300
7 to 30 days2,000
30 days or olderyour 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.

SignalWhat it readsLineSevere
Delivery delaysGmail mail that Gmail delayedunder 5%20% or more
Opens, Gmail vs. othersGmail's open rate as a share of other inboxes'at least halfunder a quarter
Hard bouncesAddresses that bounced for goodunder 2%5% or more
Full or inactive mailboxesMailboxes that answered full or inactiveunder 2%5% or more
Spam complaintsComplaints over mail to providers that report themunder 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.clicked events, 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.opened events. 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) or enforced, 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) or receive_only (nothing goes out, and capabilities.sending reads disabled). An inbox whose address or from_name looks like a well-known brand only replies, even when the mode is open (see Shared domain).
  • sending_mode_reason: why it is below open: plan (the team's plan) or instance (the instance's settings); null when 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.

On this page