Publishing SPF, DKIM and DMARC for a sending domain, by API

The email domain check returns the exact records it wants; the DNS API writes them. One loop, no copy-paste, and the record ordering that avoids a deliverability dip.

Email authentication is usually a copy-paste exercise between two dashboards. On Infrai it’s a loop: POST /v1/email/domain/verify returns the exact DNS records the sending domain needs — SPF, DKIM, DMARC and a tracking CNAME, each with a name, a value and a recommended TTL — and PUT /v1/dns/record/upsert writes them into the zone you added with POST /v1/dns/domain/add.

Same credential on both halves, so no values get transcribed by a human and no typo takes a day to find.

What the email side asks for

curl -sS -X POST "https://api.infrai.cc/v1/email/domain/verify" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"domain": "mail.northwind.example"}'
{
  "ok": true,
  "data": {
    "domain": "mail.northwind.example",
    "domain_id": "dom_oBBCwAy6hdOwIXNGu13hMtrx",
    "status": "pending_dns",
    "dns_records": [
      {"type": "TXT", "name": "mail.northwind.example",
       "value": "v=spf1 include:_spf.infrai.cc ~all", "purpose": "spf", "ttl_recommended": 3600},
      {"type": "TXT", "name": "cf._domainkey.mail.northwind.example",
       "value": "v=DKIM1;k=rsa;p=MIIBIj...AB", "purpose": "dkim", "ttl_recommended": 3600},
      {"type": "CNAME", "name": "track.mail.northwind.example",
       "value": "tracking.infrai.cc", "purpose": "tracking", "ttl_recommended": 3600},
      {"type": "TXT", "name": "_dmarc.mail.northwind.example",
       "value": "v=DMARC1;p=none;rua=mailto:dmarc@infrai.cc", "purpose": "dmarc", "ttl_recommended": 3600}
    ],
    "warm_up_state": "not_started",
    "daily_limit_current": 50000,
    "daily_limit_target": 500000
  }
}

Four records, each with a purpose you can branch on. status: "pending_dns" means the platform is waiting for them to appear.

The warm_up_state and the two daily limits are worth noticing while you’re here: a brand-new sending domain starts with a lower ceiling and earns its way up, so authentication is necessary but not sufficient for volume.

Write them straight through

import os
import time

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 requested_records(domain: str) -> dict:
    resp = SESSION.post(f"{API}/v1/email/domain/verify", json={"domain": domain}, timeout=30)
    body = resp.json()
    if not body.get("ok"):
        raise RuntimeError(body["error"]["code"])
    return body["data"]


def publish(zone_id: str, records: list[dict]) -> list[str]:
    """Write exactly what the email side asked for. `upsert` rather than `create`
    so a re-run — or a DKIM rotation — replaces the value instead of adding a
    second conflicting record, which for SPF in particular is fatal: two SPF TXT
    records at the same name is an invalid configuration and receivers treat it as
    a permanent error rather than choosing one."""
    written = []
    for record in records:
        resp = SESSION.put(
            f"{API}/v1/dns/record/upsert",
            json={
                "zone_id": zone_id,
                "record_type": record["type"],
                "name": record["name"],
                "content": record["value"],
                "ttl": record.get("ttl_recommended", 3600),
            },
            timeout=30,
        )
        resp.raise_for_status()
        written.append(f"{record['purpose']}: {record['type']} {record['name']}")
    return written


def setup(domain: str, zone_id: str, attempts: int = 12) -> dict:
    asked = requested_records(domain)
    written = publish(zone_id, asked["dns_records"])

    status = asked["status"]
    for _ in range(attempts):
        if status == "verified":
            break
        time.sleep(30)
        status = requested_records(domain)["status"]

    return {"domain": domain, "status": status, "written": written,
            "daily_limit": asked.get("daily_limit_current")}


if __name__ == "__main__":
    print(setup("mail.northwind.example", os.environ["ZONE_ID"]))

The SPF note in that docstring is the single most important detail on this page. Two SPF records at one name is not “one wins” — it’s an invalid configuration, and receivers may treat the domain as unauthenticated entirely. Upsert makes that impossible; create makes it likely.

What each record does

PurposeTypeWithout it
spfTXTreceivers can’t tell the platform may send for you
dkimTXTmessages aren’t cryptographically signed; forwarding breaks alignment
dmarcTXTyou get no reports and no policy; spoofing is unconstrained
trackingCNAMEopen and click tracking links use a shared host, not yours

DKIM is the one that matters most for deliverability, because it survives forwarding in a way SPF doesn’t. DMARC is the one that matters most for your brand, since it’s what tells receivers to reject mail that pretends to be you.

Start DMARC at p=none — which is what the platform suggests — and read the reports for a few weeks before tightening to quarantine or reject. Publishing p=reject on day one is how you discover that your CRM has been sending as your domain the whole time.

Rotating DKIM without a gap

curl -sS -X POST "https://api.infrai.cc/v1/email/domain/rotate_dkim/mail.northwind.example" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

The response has the same dns_records shape carrying the new key, so rotation is the identical loop: read the records, upsert them. Because upsert replaces the value at that name, mail signed with the new key validates as soon as DNS propagates.

Keep the TTL at the recommended value rather than something aggressive. A 3600-second TTL on DKIM means a rotation takes up to an hour to be visible everywhere, and that’s the trade you want — short TTLs on authentication records mean more resolver traffic against records that rarely change.

Check the result rather than assuming

curl -sS "https://api.infrai.cc/v1/email/domain/get/mail.northwind.example" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

That returns verification and reputation, which is the read to put on a status page in your own admin tool. And the DNS side:

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}"

Schedule both on POST /v1/cron/create. Authentication records get deleted by well-meaning cleanups more often than you’d think, and the first symptom is a deliverability drop nobody connects to DNS.

Limitations

The zone has to be one this API manages, which means the customer delegated it to Cloudflare’s nameservers.

A customer who keeps their DNS at Route 53, Namecheap or GoDaddy gets the dns_records array from the verify call and adds the records by hand — the loop above doesn’t apply to them at all, so for that population this page is a better instruction page rather than an automation, and if most of your customers are in that group the automation is worth less than it looks.

The DNS side supports A, AAAA, CNAME, TXT and MX only, so if your mail setup needs anything else it isn’t a good fit. And BIMI, MTA-STS and TLS-RPT aren’t part of what the verify call requests, so a full deliverability programme goes beyond these four records.

What’s genuinely easier is that both halves are one credential: the sending domain, the DNS zone, the scheduled drift check and the alert through POST /v1/email/send sit on one account and one invoice, so there’s no window where your email vendor wants a record your DNS vendor hasn’t got. Both route families report billing_class: free in discovery; sending is the billable part, live in GET /v1/discovery/email.send (verified 2026-09-21) and drifting downward as vendor contracts improve.

References

Browse more dns developer guides