email-routing-subdomain-onboarding
id: email-routing-subdomain-onboardingkind: platform-limitmeasured_on: 2026-08-03stale_when: > Cloudflare ships an API or wrangler command for adding a subdomain to Email Routing, or exposes Email Routing's managed MX records through the DNS records API; or `/zones/{zone_id}/email/sending/subdomains` stops answering, changes shape, or starts placing a sending subdomain's required records outside that subdomainvalues: routing.subdomain_api_available: 0 routing.subdomain_dashboard_only: 1 routing.mx_records_visible_in_dns_api: 0 routing.subdomain_receives_external_mail: 1 routing.cf_sending_reaches_own_routing_domain: 0 sending.subdomain_api_available: 1 sending.subdomain_records_stay_within_subdomain: 1 sending.apex_and_subdomain_both_onboardable: 1 sending.onboard_creates_dns_records: 1 sending.onboard_post_idempotent: 0 sending.unonboard_delete_idempotent: 0 sending.unonboard_removes_every_created_record: 0 sending.onboard_records_appear_atomically: 0 routing.rule_accepted_for_unonboarded_subdomain: 1 routing.rule_creates_subdomain_records: 0Addition, 16 September 2026: the rules API accepts a rule that can never match (#92’s drill)
Section titled “Addition, 16 September 2026: the rules API accepts a rule that can never match (#92’s drill)”routing.subdomain_dashboard_only: 1 above is true and too coarse. It says the onboarding flow has no
API, which is right. It does not say what happens if you skip the flow and create a rule anyway, and that
turns out to be the dangerous part.
POST /zones/{id}/email/routing/rules, with a to matcher on restore@drill.arbuilder.app, where
drill.arbuilder.app had never been added to Email Routing:
{ "result": { "id": "ee48fc2f…", "enabled": true, "source": "api", "matchers": [{ "type": "literal", "field": "to", "value": "restore@drill.arbuilder.app" }] } }Accepted. 200. Enabled. And inert: no MX record was created on the subdomain (checked against
Cloudflare’s own authoritative nameserver, not a resolver), and GET /email/routing/dns still lists only
the five apex records. Mail to that address never reaches Cloudflare at all, because the name it is sent to
has no MX.
So the rule exists, reads as configured in every listing, and receives nothing for ever. That is the failure shape this repository keeps meeting: a success that does nothing, and it is worse than the refusal the coarse finding implied.
Why the dashboard feels different, which is the whole explanation
Section titled “Why the dashboard feels different, which is the whole explanation”An operator adding a subdomain through Settings → Subdomains never touches MX by hand, because that flow onboards the subdomain and writes its records. The rules API does only the matcher. Both are true at once, and reading the first as “subdomains are automatic” is how a Node could offer a routing rule that silently never fires.
What receiving on a subdomain actually needs
Section titled “What receiving on a subdomain actually needs”Two things, and only one of them lacks an API:
| API? | |
|---|---|
| a routing rule for the address | yes; measured above |
| MX records on the subdomain | yes; POST /zones/{id}/dns_records |
Neither is the dashboard-only onboarding wizard. So a Node holding dns.write could onboard a receiving
subdomain end to end, which is what #163 L2 was reaching for. This Node holds six scopes and DNS write is
not among them, deliberately. DNS write on a customer’s zone is the authority to redirect their mail, and
it is a larger grant than anything Mailda holds today.
Recorded as a capability decision rather than a limit, because the earlier finding read as the latter.
Addition, 13 September 2026: the onboard run through the Node’s own grant (#163 L2)
Section titled “Addition, 13 September 2026: the onboard run through the Node’s own grant (#163 L2)”drill.mailda-test.whymelabs.com, onboarded by mailda provider --onboard-sending … --confirm <digest>
against the live Node, the first act this Node has performed that changes the Cloudflare account it is
installed in.
sending.onboard_records_appear_atomically: 0
Section titled “sending.onboard_records_appear_atomically: 0”The six records do not land together. Immediately after the POST, the cf-bounce MX set and its SPF TXT
answered from the authoritative nameserver; the DKIM and DMARC TXT records did not, and arrived within
thirty seconds.
This costs nothing here and is recorded because of the design it rules out. A proposal that diffed Cloudflare’s required list against the zone’s actual DNS would report a freshly-onboarded domain as half-configured, and an operator re-running it would meet a different answer each time for the first minute. The proposal this Node builds asks Cloudflare what is onboarded rather than what is resolving, so the lag is invisible to it, which was chosen for a different reason and turns out to matter for this one too.
The digest refusing a real wrong-target confirm
Section titled “The digest refusing a real wrong-target confirm”Run live, and it is the failure this route exists for rather than a hypothetical: a proposal was taken for
other.mailda-test.whymelabs.com and its digest offered as confirmation for
drill.mailda-test.whymelabs.com.
E_PROVIDER_SENDING_STALE the proposal confirmed is not the proposal this Node would now apply fix run the proposal again and confirm the digest it prints: 04c5bcf2…Nothing reached Cloudflare. The read side had already made the neighbouring mistake once, matching an apex and printing six correct records about a domain nobody asked about, and two administrators would have approved that, which is why the confirmation here binds to the proposal rather than collecting a second signature.
Addition, 10 September 2026: the onboard drill, and the record it leaves behind (#163)
Section titled “Addition, 10 September 2026: the onboard drill, and the record it leaves behind (#163)”A create/read/delete drill against probe.mailda-test.whymelabs.com, a name invented for this, inside
Mailda’s own test subdomain, touching no zone apex and no domain carrying mail.
sending.onboard_creates_dns_records: 1: one POST, and Cloudflare writes the DNS itself
Section titled “sending.onboard_creates_dns_records: 1: one POST, and Cloudflare writes the DNS itself”POST /zones/{zone}/email/sending/subdomains {"name": "probe.mailda-test.whymelabs.com"} → 200, enabled: true, return_path_domain: cf-bounce.probe.mailda-test.whymelabs.comWithin a minute, live in public DNS, by dig against Cloudflare’s authoritative nameserver, not the API’s
own account of itself:
cf-bounce.probe.… MX 12 route3 / 25 route1 / 34 route2 .mx.cloudflare.net.cf-bounce.probe.… TXT "v=spf1 include:_spf.mx.cloudflare.net ~all"_dmarc.probe.… TXT "v=DMARC1; p=reject;"This settles a question the guide could not: Cloudflare’s documentation describes the dashboard flow
adding those records, and whether the API POST did the same was a separate fact. It does. So a Node
onboarding a sending domain makes one call and writes no DNS record itself, which is a smaller and
safer write than proposing a record list, and it means the diff to show an operator is “this domain is not
onboarded” rather than a set of records Mailda believes are needed.
sending.onboard_post_idempotent: 0 and sending.unonboard_delete_idempotent: 0
Section titled “sending.onboard_post_idempotent: 0 and sending.unonboard_delete_idempotent: 0”Neither verb is safe to repeat. A second POST of the same name answers 2040 Subdomain already exists; a
second DELETE answers 2033 Subdomain not found. So an apply that retried on a timeout would report a
failure about a state it had itself reached, and the caller has to read the existing state to tell “already
done” from “refused”.
sending.unonboard_removes_every_created_record: 0: the one that matters
Section titled “sending.unonboard_removes_every_created_record: 0: the one that matters”DELETE is not the inverse of POST. Six records were created; five were removed. The DMARC record
was not:
$ dig +short TXT _dmarc.probe.mailda-test.whymelabs.com @margaret.ns.cloudflare.com"v=DMARC1; p=reject;"Queried three times over a minute against the authoritative nameserver, so it is not a cache. A
nonexistent sibling and a random label under the same zone both answer empty, so it is not a wildcard. The
record is real, it outlived the object that created it, and nothing in the API’s response said so. The
DELETE answered success: true.
The hazard is specific and quiet. An operator who tries a sending subdomain and backs out is left holding a
p=reject DMARC policy on a name Cloudflare no longer manages and no surface lists. Point that
subdomain at another mail provider later and the stray policy rejects its mail, for a reason nothing
connects to a trial that was undone weeks earlier.
So L2’s write side cannot treat un-onboarding as an undo. It has to either remove the leftover itself, which needs a DNS write scope this grant does not carry, or name the record it could not remove.
Addition, 10 September 2026: Email Sending subdomains have a full API, and it moves where L2 can write (#163)
Section titled “Addition, 10 September 2026: Email Sending subdomains have a full API, and it moves where L2 can write (#163)”This receipt’s stale_when names “Cloudflare ships an API … for adding a subdomain”. It has, for the
sending half. routing.subdomain_api_available: 0 above is re-measured today and unchanged.
GET /zones/{zone}/email/routing/subdomains still answers a plain-text 404 page not found, the shape an
unrouted path gives. But its sibling is real:
| method | path |
|---|---|
| GET | /zones/{zone_id}/email/sending/subdomains |
| GET | /zones/{zone_id}/email/sending/subdomains/{subdomain_id} |
| POST | /zones/{zone_id}/email/sending/subdomains |
| PATCH | /zones/{zone_id}/email/sending/subdomains/{subdomain_id} |
| DELETE | /zones/{zone_id}/email/sending/subdomains/{subdomain_id} |
| GET | /zones/{zone_id}/email/sending/subdomains/{subdomain_id}/dns |
The list GET answers 200 on whymelabs.com with two entries, the apex and mailda-test.whymelabs.com,
onboarded separately (sending.apex_and_subdomain_both_onboardable: 1). Each carries id, name,
enabled, preview_enabled, return_path_domain, dkim_selector, drop_suppressed_recipients.
The path this was nearly recorded as absent under
Section titled “The path this was nearly recorded as absent under”Two wrong paths were probed first, /accounts/{acct}/email/sending/domains and
/zones/{zone}/email/sending/domains, and the first answered 404 with
10001 Unable to authenticate request in the API envelope, which reads as the route exists and this
token may not. It does not mean that: a token holding email_sending (write) gets the same answer, because
the path is simply wrong. An invented path under the same prefix answers 7003 Could not route, so the two
error shapes do not reliably separate “unrouted” from “unauthorized”, and a measurement that leaned on
that distinction would have been recorded confidently and wrongly. The reference settled it.
sending.subdomain_records_stay_within_subdomain: 1, which is the load-bearing one
Section titled “sending.subdomain_records_stay_within_subdomain: 1, which is the load-bearing one”GET …/subdomains/{id}/dns for mailda-test.whymelabs.com returns six records, and every one of them is
at or under that subdomain:
MX cf-bounce.mailda-test.whymelabs.com route1|2|3.mx.cloudflare.net.TXT cf-bounce.mailda-test.whymelabs.com "v=spf1 include:_spf.mx.cloudflare.net ~all"TXT cf-bounce._domainkey.mailda-test.whymelabs.com "v=DKIM1; …"TXT _dmarc.mailda-test.whymelabs.com "v=DMARC1; p=reject;"Email Routing’s required records land on the zone apex, whymelabs.com, which carries live mail.
Email Sending’s do not. So #163 L2’s write side is exercisable against a test subdomain without
proposing a single change to a production zone, which is the opposite of what was believed before this was
measured.
The apex is onboarded too, and picking the wrong one is silent
Section titled “The apex is onboarded too, and picking the wrong one is silent”Both are in the list, so “which sending domain covers mailda-test.whymelabs.com” has two true answers
and one right one. Taking the first match returned the apex, and the six records it printed were all
correct, about whymelabs.com. A proposal built from them would have written into the production zone
while reporting the test subdomain’s name at the top. The rule is longest match wins, and it was found by
running the read against a real zone rather than by a fixture, which had one entry and so no ambiguity.
Measured: against whymelabs.com on a Workers Paid account, 3 August 2026. A probe
Worker was deployed and deleted; no DNS record was created, and the zone’s 15 routing rules
and enabled catch-all were left untouched.
The question
Section titled “The question”§10’s domain topology makes a delegated operational subdomain, ops.example.com or
mail.example.com, the default for a new installation, so an existing root-domain MX is
never disturbed. #20 may promote it further, to the recommended evaluation path. Can
mailda deploy onboard one, or does it need a human in the dashboard?
Result: dashboard-only, on current evidence
Section titled “Result: dashboard-only, on current evidence”No API endpoint exists. Probed against a real zone with a working token:
| Endpoint | Result |
|---|---|
GET /zones/{zone}/email/routing | 200 |
GET /zones/{zone}/email/routing/dns | 200 |
GET /zones/{zone}/email/routing/subdomains | 404 |
GET /zones/{zone}/email/routing/domains | 404 |
GET /accounts/{acct}/email/routing | 400 |
GET /accounts/{acct}/email/sending/domains | 404 |
Wrangler cannot reach it either. wrangler email routing settings mailda-test.whymelabs.com
fails with “Could not find zone for mailda-test.whymelabs.com”. Its email commands resolve
a zone, and a subdomain is not one.
Cloudflare documents only the dashboard flow: “Select the apex domain, then open Settings. Under Subdomains, enter the subdomain you want to enable in the inline form and submit it.”
Why the DNS route is not an obvious workaround
Section titled “Why the DNS route is not an obvious workaround”whymelabs.com already has a working Email Routing subdomain, medstocks.whymelabs.com.
Comparing the two sources of truth:
GET /zones/{zone}/dns_records?type=MX -> 0 recordsdig +short MX whymelabs.com -> 25 route1.mx.cloudflare.net, 34 route2…dig +short MX medstocks.whymelabs.com -> 12 route3.mx.cloudflare.net, 25 route1…Real MX records exist in public DNS for both the apex and the subdomain, and none of them appear in the DNS records API. Email Routing’s records are service-managed rather than ordinary zone records, which is consistent with the documented “locked records” behaviour. So the subdomain’s MX is not something that was added through the DNS API and cannot be inspected or reproduced through it.
Confirmed after dashboard onboarding: a subdomain behaves exactly like the apex
Section titled “Confirmed after dashboard onboarding: a subdomain behaves exactly like the apex”The subdomain was onboarded through the dashboard, and MX records appeared in public DNS:
dig +short MX mailda-test.whymelabs.com 12 route3.mx.cloudflare.net. 25 route1.mx.cloudflare.net. 34 route2.mx.cloudflare.net.A routing rule created with --match-value probe@mailda-test.whymelabs.com --action-type worker
was accepted with identical syntax to an apex rule, and a real message from Gmail was
delivered to the Worker:
{ "f": "wmhy.tech@gmail.com", "t": "probe@mailda-test.whymelabs.com", "s": "Testing", "bytes": 7154, "at": "2026-08-03T15:17:53.827Z" }So once onboarded, nothing about a subdomain is special: same rule syntax, same worker
action, same delivery. The only obstacle is the onboarding step itself, which has no API.
A separate finding that matters more: Cloudflare cannot test its own inbound
Section titled “A separate finding that matters more: Cloudflare cannot test its own inbound”Two sends via wrangler email sending send from weimeng.soh@whymelabs.com, one to the
subdomain and one to an apex control address, never arrived, while the external Gmail
message arrived within seconds. Both sends reported Email sent successfully.
Cloudflare Email Sending therefore does not deliver to a domain whose MX points at Cloudflare Email Routing in the same account. Loop protection is the obvious explanation; the mechanism was not confirmed.
This constrains §5A directly. Step 6’s synthetic inbound test is the step that proves a Node actually receives mail, and it cannot generate its own test message using Cloudflare Email Sending, because that message will be accepted, reported as sent, and never delivered. The test needs an external sender or it proves nothing while appearing to pass, which is the worst kind of test.
Recorded as a requirement rather than a curiosity: whatever performs the synthetic inbound check must originate outside the Node’s own account.
What could not be tested, and why
Section titled “What could not be tested, and why”One question remains unanswered: whether adding ordinary MX records for a subdomain would be sufficient on its own, or whether the dashboard flow also registers the subdomain internally. The subdomain used here was onboarded through the dashboard, so both happened together. Creating records directly failed:
POST /zones/{zone}/dns_records -> Authentication errorThe available OAuth token holds zone (read) and no DNS write scope. So this remains open,
and it matters: if plain MX records suffice, install could automate subdomain onboarding with
a DNS-capable token. If they do not, no token helps and the dashboard is mandatory.
Consequence
Section titled “Consequence”§5A’s default install path cannot currently be automated. mailda deploy must either
hand the user a dashboard instruction mid-install, or fall back to an apex domain, which is
exactly the higher-stakes DNS change §10’s subdomain default exists to avoid.
That is a real cost on #20’s “promote the subdomain to default” option, and it should be weighed there rather than discovered during implementation.
Incidental finding worth its own attention
Section titled “Incidental finding worth its own attention”whymelabs.com has four routing rules for medstocks.whymelabs.com addresses, and that
subdomain does resolve MX, so those are live. But nothing in the API prevents creating a
routing rule for a subdomain that has no MX records at all. Such a rule would be accepted,
appear healthy in every listing, and silently never receive anything.
Mailda must not reproduce that. A route is not proven by its existence. §5A step 8’s synthetic inbound test exists precisely to prove delivery, and this is a concrete example of why configuration alone is not evidence.
Residual
Section titled “Residual”- Whether plain MX records suffice for subdomain acceptance: untested, blocked on DNS write permission.
- Email Sending on a subdomain is separately onboarded per the docs, with its own
cf-bounceMX, SPF, DKIM and DMARC records. Untested. - How subdomains count against the 30-domains-per-zone limit: untested.