Domain verification keeps failing: what the check looks at

Verification reads delegation and records from public DNS, so the usual causes are propagation, the registrar not saving, or a record at the wrong name. A diagnosis order.

When POST /v1/dns/domain/verify on Infrai keeps answering state: "pending", the platform isn’t being stubborn — it’s reading public DNS and not finding what it expects. Nearly every case is one of four things: delegation hasn’t propagated, the registrar didn’t actually save the change, the record is at a slightly different name than you think, or there are two conflicting records and the wrong one is winning.

Diagnose in that order. It’s roughly the order of likelihood.

What the call reports

curl -sS -G "https://api.infrai.cc/v1/dns/domain/get" \
  --data-urlencode "domain=northwind.example" \
  --data-urlencode "zone_id=3a9d545444dc7a02e085889a3f713078" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "zone_id": "3a9d545444dc7a02e085889a3f713078",
    "domain": "northwind.example",
    "state": "pending",
    "name_servers": ["lilyana.ns.cloudflare.com", "margo.ns.cloudflare.com"]
  }
}

state and name_servers are the two fields that matter. The nameservers listed are what the domain must be delegated to — not a generic pair, but the ones assigned to this zone — and pending means the public answer doesn’t match them yet.

Re-checking is a separate call and safe to repeat:

curl -sS -X POST -G "https://api.infrai.cc/v1/dns/domain/verify" \
  --data-urlencode "domain=northwind.example" \
  --data-urlencode "zone_id=3a9d545444dc7a02e085889a3f713078" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Cause one: it just hasn’t propagated

Delegation changes are cached by resolvers worldwide, and the registrar’s own TTL governs how long the old answer sticks. Minutes is common; up to a day happens.

Check from outside the platform before you debug anything else:

dig +short NS northwind.example
dig +short NS northwind.example @1.1.1.1
dig +short NS northwind.example @8.8.8.8

If those three disagree, you’re watching propagation and the answer is to wait. If they all agree and none of them match the name_servers from the API, the change never landed — which is cause two.

Cause two: the registrar didn’t save it

More common than it should be. Registrar consoles lose changes to a session timeout, apply them to the wrong domain in a multi-domain account, or require a separate confirmation step that looks optional.

Ask the customer to re-open the registrar and read the values back to you rather than confirming they “set them”. Reading is different from remembering.

Cause three: the record is at the wrong name

This one produces the most confusion, because the record exists and looks right.

_dmarc.mail.northwind.example and _dmarc.northwind.example are different names. So are mail.northwind.example and mail.northwind.example.northwind.example, which is what you get when a registrar UI appends the zone to a value you already fully qualified. That trailing-domain duplication is the single most common paste error in DNS, and it’s invisible unless you look at the resolved name rather than the field you typed into.

List what’s actually there:

curl -sS -G "https://api.infrai.cc/v1/dns/record/list" \
  --data-urlencode "zone_id=3a9d545444dc7a02e085889a3f713078" \
  --data-urlencode "record_type=TXT" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Cause four: two records, wrong one winning

Two TXT records at the same name is legal DNS, and for SPF specifically it’s an invalid configuration that receivers treat as a failure. Two CNAMEs at one name is worse — resolvers pick.

If you used POST /v1/dns/record/create from a job that retried, you have duplicates. Switch the writer to PUT /v1/dns/record/upsert and clean up what’s there.

A diagnosis script

import os
import subprocess

import requests

API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})


def platform_view(domain: str, zone_id: str) -> dict:
    resp = SESSION.get(f"{API}/v1/dns/domain/get",
                       params={"domain": domain, "zone_id": zone_id}, timeout=25)
    resp.raise_for_status()
    return resp.json()["data"]


def public_nameservers(domain: str, resolver: str = "1.1.1.1") -> list[str]:
    """Ask a public resolver what the world sees. The platform's view and the
    world's view disagreeing IS the diagnosis."""
    try:
        out = subprocess.run(["dig", "+short", "NS", domain, f"@{resolver}"],
                             capture_output=True, text=True, timeout=10)
        return sorted(line.rstrip(".").lower() for line in out.stdout.split() if line)
    except (OSError, subprocess.SubprocessError):
        return []


def records(zone_id: str) -> list[dict]:
    resp = SESSION.get(f"{API}/v1/dns/record/list", params={"zone_id": zone_id}, timeout=25)
    resp.raise_for_status()
    return resp.json()["data"].get("records", [])


def diagnose(domain: str, zone_id: str) -> dict:
    view = platform_view(domain, zone_id)
    expected = sorted(ns.rstrip(".").lower() for ns in view.get("name_servers", []))
    seen = {r: public_nameservers(domain, r) for r in ("1.1.1.1", "8.8.8.8", "9.9.9.9")}

    disagreeing = len({tuple(v) for v in seen.values() if v}) > 1
    matches = any(sorted(v) == expected for v in seen.values() if v)

    names = [r["name"] for r in records(zone_id)]
    suspicious = [n for n in names if n.count(domain) > 1]
    duplicated = sorted({n for n in names if names.count(n) > 1})

    if disagreeing:
        verdict = "propagating — resolvers disagree; wait and re-check"
    elif not any(seen.values()):
        verdict = "no NS answer at all — the domain may not be registered"
    elif not matches:
        verdict = "delegation never landed — have the registrar values read back"
    elif suspicious:
        verdict = f"record name has the zone appended twice: {suspicious}"
    elif duplicated:
        verdict = f"duplicate records at one name: {duplicated}"
    else:
        verdict = "DNS looks right — re-run verify; the platform may not have re-checked yet"

    return {"state": view.get("state"), "expected_ns": expected, "seen": seen, "verdict": verdict}


if __name__ == "__main__":
    for key, value in diagnose(os.environ["DOMAIN"], os.environ["ZONE_ID"]).items():
        print(f"{key:>14}: {value}")
SymptomVerdictAction
Resolvers disagreepropagatingwait, re-check on a schedule
No NS answer anywheredomain not registered or expiredcustomer’s registrar
NS answer doesn’t matchdelegation never appliedread values back at the registrar
Name contains the zone twicepaste error in the consolefix the name
Two records at one nameduplicate writesupsert, then clean up

Tell the customer, don’t make them ask

The worst version of this is silence. If verification is still pending after an hour, that’s a notification: POST /v1/email/send on the same key, with the two nameserver values and a link back into your onboarding page. A customer who hears from you before they email support has a different opinion of your product.

Schedule the re-check with POST /v1/cron/create rather than polling in a request handler, and record the result with POST /v1/logs/ingest so the history exists when someone asks how long it took.

Limitations

Verification reads public DNS, so it can only see what the world sees — there’s no way for it to inspect a registrar account or explain why a change didn’t apply. A split-horizon setup, where the customer’s internal resolver answers differently from the public one, will verify correctly and still behave oddly for their staff, and nothing here detects that.

There’s also no webhook for “domain became verified”, so the polling loop above is yours. Cloudflare’s own API, which backs these routes, exposes more zone diagnostics if you need to go deeper.

What’s on the same credential is the rest of the onboarding: the DNS zone, the email domain verification that depends on it via POST /v1/email/domain/verify, the schedule, the log line and the customer notification — one key, one invoice, one GET /v1/account/usage. DNS routes report billing_class: free in discovery (verified 2026-09-21), and platform rates trend downward over time.

References

Browse more dns developer guides