Diagnostic Deliverability

provider records added to the root instead of the sending subdomain

The provider gave you three CNAMEs. You pasted them into your DNS host, waited, and verification is still pending an hour later. dig says the records exist and resolve correctly. They do — they are just not where the provider is looking. The records were meant for send.yourdomain.com and they went on yourdomain.com, one label short.

Any provider Python and Node.js Detect through DNS
The short answer

Most providers issue records for a sending subdomain, not the apex. If the record name is s1._domainkey.send.example.com and you created s1._domainkey.example.com, verification will never pass no matter how long you wait.

Resolve the exact name the provider expects and compare. The script takes the expected records, checks each one at the precise name, and tells you whether it is missing, wrong, or present at the wrong depth — which is a different fix from the other two.

The problem in plain words

Nothing errors. The DNS host accepted the records, they resolve, and a spot check with dig looks fine. The provider dashboard just sits at pending indefinitely, which reads as slow propagation rather than a mistake, so people wait days before questioning it.

The subdomain detail is easy to lose because DNS hosts differ in how they treat the name field. Some want the full name, some want only the part before your domain, and pasting a full name into a host that appends your domain gives you a record one label too deep instead of one too shallow — the same failure from the opposite direction.

Why it happens

Providers separate sending from your main domain on purpose. A dedicated subdomain keeps sending reputation apart from the rest of your mail, and it lets them put an MX record for the Return-Path without touching the MX that receives your mail.

DNS hosts disagree about the name field. Cloudflare wants the full name, most cPanel-style hosts want the relative part. The same paste produces different results, and neither errors.

Verification failure looks like latency. Nothing distinguishes 'not propagated yet' from 'looking in the wrong place', so the natural response is to wait, which never resolves it.

How to fix it

Get the exact names from the provider

Not from the docs, from your own account — the tokens are per domain. Every provider exposes this: Postmark returns DKIMPendingHost and DKIMPendingTextValue, SendGrid returns the CNAME set from the domain authentication endpoint, Resend and Mailgun expose the same on their domain objects.

Resolve that exact name, not a shortened one

Query the fully qualified name character for character. A record at a different depth resolves perfectly and is still useless.

dig +short s1._domainkey.send.example.com CNAME
dig +short s1._domainkey.example.com CNAME    # the wrong-depth version

Work out which way your DNS host wants the name

Create one test record and resolve it. If you entered send and got send.example.com, the host appends. If you entered send.example.com and got send.example.com.example.com, it appends and you have just found your bug.

Fix the depth rather than adding more records

Delete the wrong-depth record. Leaving it costs nothing but makes the next person's diagnosis harder, and a stray _domainkey at the apex will confuse anyone auditing DKIM later.

How to check it worked

The provider is the authority. Re-trigger verification and read its own status rather than trusting DNS to look right:

python provider_dns_check.py --expect expected.json
# every record: OK

# then ask the provider to re-check
curl -sX POST https://api.provider.example/domains/<id>/verify \
  -H "Authorization: Bearer $API_KEY"

The full code

The script takes the expected records as JSON — name, type and value, exactly as the provider gave them — and resolves each one. It distinguishes three failures that need different fixes: missing entirely, present with the wrong value, and present at the wrong depth, which is the one people misdiagnose as propagation delay.

provider_dns_check.py
"""Check an email provider's expected DNS records actually exist, exactly.

Takes the records the provider issued and resolves each at its precise name. The
useful part is distinguishing three failures that look identical in a dashboard:
missing, wrong value, and right record at the WRONG DEPTH -- the last of which is
usually mistaken for slow propagation and waited out for days.
"""
import argparse
import json
import logging
import sys

import dns.resolver

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("provider_dns_check")


def resolve(name, rtype):
    try:
        answers = dns.resolver.resolve(name, rtype)
        return [str(a).strip('"').rstrip(".") for a in answers]
    except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
        return []


def diagnose(expected, resolver=resolve):
    """Pure-ish decision function; the resolver is injected so tests run offline.

    `expected` is {name, type, value}. Checks the exact name first, then two common
    wrong depths, so the message can say what actually happened rather than just
    'not found'.
    """
    name, rtype, want = expected["name"], expected["type"], expected["value"].rstrip(".")
    got = resolver(name, rtype)
    if want in got:
        return "OK", f"{name} -> {want}"
    if got:
        return "WRONG VALUE", f"{name} resolves to {got[0]}, expected {want}"

    # Not at the exact name. Is it one label short, or doubled up? Both are common
    # and both are entered by a human who did not know which their DNS host wanted.
    labels = name.split(".")
    apex = ".".join(labels[-2:])
    shallow = labels[0] + "." + apex if len(labels) > 3 else None
    doubled = f"{name}.{apex}"
    if shallow and want in resolver(shallow, rtype):
        return "WRONG DEPTH", f"found at {shallow}, provider wants {name}"
    if want in resolver(doubled, rtype):
        return "WRONG DEPTH", f"found at {doubled}; your DNS host appended the domain"
    return "MISSING", f"{name} does not resolve"


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--expect", required=True,
                    help='JSON file: [{"name":..., "type":"CNAME", "value":...}]')
    args = ap.parse_args()

    expected = json.loads(open(args.expect).read())
    failed = False
    for rec in expected:
        state, detail = diagnose(rec)
        if state == "OK":
            log.info("OK          %s", detail)
        else:
            failed = True
            log.error("%-11s %s", state, detail)
    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(main())
provider-dns-check.mjs
/**
 * Check an email provider's expected DNS records actually exist, exactly.
 *
 * The useful part is distinguishing three failures that look identical in a
 * dashboard: missing, wrong value, and right record at the WRONG DEPTH -- the last
 * of which is usually mistaken for slow propagation and waited out for days.
 */
import { promises as dns } from 'node:dns';
import { readFile } from 'node:fs/promises';

async function resolve(name, rtype) {
  try {
    if (rtype === 'CNAME') return (await dns.resolveCname(name)).map((v) => v.replace(/\.$/, ''));
    if (rtype === 'TXT') return (await dns.resolveTxt(name)).map((c) => c.join(''));
    if (rtype === 'MX') return (await dns.resolveMx(name)).map((m) => m.exchange);
    return [];
  } catch {
    return [];
  }
}

/**
 * Pure-ish decision function; the resolver is injected so tests run offline.
 * `expected` is {name, type, value}.
 */
export async function diagnose(expected, resolver = resolve) {
  const { name, type } = expected;
  const want = expected.value.replace(/\.$/, '');
  const got = await resolver(name, type);
  if (got.includes(want)) return { state: 'OK', detail: `${name} -> ${want}` };
  if (got.length) {
    return { state: 'WRONG VALUE', detail: `${name} resolves to ${got[0]}, expected ${want}` };
  }

  // Not at the exact name. One label short, or doubled up? Both are common and both
  // are entered by a human who did not know which their DNS host wanted.
  const labels = name.split('.');
  const apex = labels.slice(-2).join('.');
  const shallow = labels.length > 3 ? `${labels[0]}.${apex}` : null;
  const doubled = `${name}.${apex}`;
  if (shallow && (await resolver(shallow, type)).includes(want)) {
    return { state: 'WRONG DEPTH', detail: `found at ${shallow}, provider wants ${name}` };
  }
  if ((await resolver(doubled, type)).includes(want)) {
    return { state: 'WRONG DEPTH', detail: `found at ${doubled}; your DNS host appended the domain` };
  }
  return { state: 'MISSING', detail: `${name} does not resolve` };
}

async function main() {
  const file = process.argv[process.argv.indexOf('--expect') + 1];
  const expected = JSON.parse(await readFile(file, 'utf8'));
  let failed = false;
  for (const rec of expected) {
    const { state, detail } = await diagnose(rec);
    if (state === 'OK') console.log(`OK          ${detail}`);
    else { failed = true; console.error(`${state.padEnd(11)} ${detail}`); }
  }
  process.exit(failed ? 1 : 0);
}

if (import.meta.url === `file://${process.argv[1]}`) main();

Add a test

Both wrong-depth cases are worth pinning down, because they are the ones a dashboard cannot distinguish from propagation delay and the ones people wait out for days.

test_provider_dns_check.py
from provider_dns_check import diagnose

EXPECT = {"name": "s1._domainkey.send.example.com", "type": "CNAME",
          "value": "s1.dkim.provider.net"}


def fake(mapping):
    return lambda name, rtype: mapping.get(name, [])


def test_correct_record_passes():
    state, _ = diagnose(EXPECT, fake({EXPECT["name"]: ["s1.dkim.provider.net"]}))
    assert state == "OK"


def test_wrong_value_is_distinguished_from_missing():
    state, detail = diagnose(EXPECT, fake({EXPECT["name"]: ["s1.dkim.other.net"]}))
    assert state == "WRONG VALUE"
    assert "expected" in detail


def test_record_one_label_too_shallow():
    """Added at the apex instead of the sending subdomain."""
    state, detail = diagnose(EXPECT, fake({"s1._domainkey.example.com": ["s1.dkim.provider.net"]}))
    assert state == "WRONG DEPTH"
    assert "provider wants" in detail


def test_dns_host_appended_the_domain():
    doubled = "s1._domainkey.send.example.com.example.com"
    state, detail = diagnose(EXPECT, fake({doubled: ["s1.dkim.provider.net"]}))
    assert state == "WRONG DEPTH"
    assert "appended" in detail


def test_nothing_anywhere_is_missing():
    state, _ = diagnose(EXPECT, fake({}))
    assert state == "MISSING"
provider-dns-check.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { diagnose } from './provider-dns-check.mjs';

const EXPECT = {
  name: 's1._domainkey.send.example.com', type: 'CNAME', value: 's1.dkim.provider.net',
};
const fake = (map) => async (name) => map[name] ?? [];

test('a correct record passes', async () => {
  const r = await diagnose(EXPECT, fake({ [EXPECT.name]: ['s1.dkim.provider.net'] }));
  assert.equal(r.state, 'OK');
});

test('wrong value is distinguished from missing', async () => {
  const r = await diagnose(EXPECT, fake({ [EXPECT.name]: ['s1.dkim.other.net'] }));
  assert.equal(r.state, 'WRONG VALUE');
});

test('a record one label too shallow', async () => {
  const r = await diagnose(EXPECT, fake({ 's1._domainkey.example.com': ['s1.dkim.provider.net'] }));
  assert.equal(r.state, 'WRONG DEPTH');
});

test('the DNS host appended the domain', async () => {
  const doubled = 's1._domainkey.send.example.com.example.com';
  const r = await diagnose(EXPECT, fake({ [doubled]: ['s1.dkim.provider.net'] }));
  assert.match(r.detail, /appended/);
});

test('nothing anywhere is missing', async () => {
  assert.equal((await diagnose(EXPECT, fake({}))).state, 'MISSING');
});

FAQ

The records resolve, so why will the domain not verify?

Because they resolve at the wrong name. Providers usually issue records for a sending subdomain such as send.example.com, and a record created at example.com resolves perfectly while being invisible to the check.

Why do providers use a subdomain at all?

It keeps sending reputation separate from your main domain, and it lets them put an MX record for the Return-Path without disturbing the MX that receives your mail.

My DNS host doubled the domain. Why?

Some hosts want the full record name and some want only the part before your domain, and neither errors on the wrong one. Pasting a fully qualified name into a host that appends your domain produces name.example.com.example.com.

How long should verification take?

Minutes to a few hours once the records are correct. If it has been more than a day, it is almost never propagation — check the exact name before waiting any longer.

Should I delete the record I added at the wrong depth?

Yes. It costs nothing to leave but it makes the next person's diagnosis harder, and a stray _domainkey record at the apex will confuse anyone auditing DKIM later.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.