Publish all three as TXT records and let a scheduled job re-apply them. The least complex thing that actually holds is a reconciler, not a setup wizard: use the mail provider's own verification call as the pass/fail signal instead of your dig output, and keep DMARC on a monitoring policy until the aggregate reports come back clean. SPF alone stops almost nothing that modern receivers act on, so the three go out together or they don't go out.
The setup is easy. Staying set up is the part that bites.
Take a marketplace where every seller sends order confirmations from their own storefront domain. Onboarding writes three TXT records into the seller's zone, the mail provider verifies them, and receipts start flowing. Six months later that same zone has an extra SPF record a web agency added for a newsletter tool, a DKIM selector that was rotated on the mail side and never republished, and a DMARC policy someone moved to p=reject in a registrar dashboard because a blog post told them to. None of it surfaces as an error. It surfaces as a support ticket saying buyers stopped getting receipts, which is an expensive way to learn that your published records and your intended records are two different things.
Drift between intent and what's actually in the zone is the axis worth designing around. Which provider, which API, which language — all of that follows from whether you can see the difference.
What should SPF, DKIM and DMARC each cover before email leaves a seller's domain?
Three records, three jobs, and they only work as a set.
SPF authorises senders. It's a TXT record at the domain apex listing the hosts allowed to use that domain in the envelope sender, ending with a qualifier — -all or ~all — that says what receivers should do with everything else. DKIM signs the message: a TXT record at <selector>._domainkey.<domain> holding the public half of the key your mail provider signs with, so a receiver can tell that headers and body weren't rewritten in transit. DMARC covers the disagreement between the first two. It's a TXT record at _dmarc.<domain> declaring what to do when SPF and DKIM don't line up, plus an address where receivers send aggregate reports.
All three are TXT. There is no SPF record type, no DKIM type, no DMARC type — people go hunting for one in a provider's dropdown, and it has never been there.
Start DMARC at p=none. Publishing a rejecting policy on day one is how a launch loses its transactional mail instead of its spam, and on a marketplace the first two weeks of aggregate reports are the only cheap way to find the senders nobody documented: the invoicing tool, the review-request service, the shipping notifier someone wired up in 2023 and forgot.
The reconcile loop, written as one TypeScript job
The data flow is small enough to hold in your head. Intended records live in code, one function per sending domain. The job writes each of the three, waits out a TTL so caches have a chance to pick up the new answer, then asks the mail side to verify the domain and stores that verdict next to the seller. Two calls carry the whole thing: PUT /v1/dns/record/upsert once per record, then POST /v1/email/domain/verify once. Run it when a seller onboards, and again every night whether anything changed or not.
// reconcile-sender-domain.ts — Node 20+, no dependencies.
// INFRAI_BASE_URL is the v1 API base; INFRAI_API_KEY is the key that covers both calls.
const BASE = process.env.INFRAI_BASE_URL;
const KEY = process.env.INFRAI_API_KEY;
const DKIM_PUBLIC_KEY = process.env.DKIM_PUBLIC_KEY;
if (!BASE || !KEY || !DKIM_PUBLIC_KEY) {
throw new Error("INFRAI_BASE_URL, INFRAI_API_KEY and DKIM_PUBLIC_KEY are required");
}
type TxtRecord = { domain: string; name: string; type: "TXT"; value: string; ttl: number };
function intended(domain: string): TxtRecord[] {
return [
{ domain, name: "@", type: "TXT", value: "v=spf1 include:mail.example.net -all", ttl: 300 },
{ domain, name: "s2._domainkey", type: "TXT", value: `v=DKIM1; k=rsa; p=${DKIM_PUBLIC_KEY}`, ttl: 300 },
{ domain, name: "_dmarc", type: "TXT", value: "v=DMARC1; p=none; rua=mailto:dmarc@marketplace.example", ttl: 300 },
];
}
async function call(method: string, path: string, body: unknown, idempotencyKey: string): Promise<unknown> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(body),
});
if (res.status === 429) {
const retryAfter = Number(res.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 1000;
await new Promise((resolve) => setTimeout(resolve, waitMs));
continue;
}
const text = await res.text();
if (!res.ok) throw new Error(`${method} ${path} -> ${res.status}: ${text}`);
return JSON.parse(text);
}
throw new Error(`${method} ${path}: rate limited on all 5 attempts`);
}
async function reconcile(domain: string): Promise<void> {
for (const record of intended(domain)) {
// The key is derived from the domain and record name, so tonight's run rewrites
// the same three records instead of adding a fourth.
await call("PUT", "/dns/record/upsert", record, `auth-records:${domain}:${record.name}`);
}
await new Promise((resolve) => setTimeout(resolve, 300_000)); // one TTL
const verdict = await call("POST", "/email/domain/verify", { domain }, `verify:${domain}`);
console.log(domain, JSON.stringify(verdict));
}
await reconcile("mail.seller-42.example");
Look at the idempotency key rather than the request bodies. It's what makes a nightly re-run safe, and safe re-runs are what turn a one-time setup step into drift correction you get for free. A retry after a timeout writes the same record instead of a second one, which matters more here than in most workflows, because two SPF records at the same apex is a permanent error for every receiver that checks — the fix for drift must not be able to cause drift.
Where the drift actually comes from
Almost none of it is malice, and almost none of it is DNS being unreliable.
Sellers own their zones, so sellers edit them. A vendor asks for another include: and someone adds it at the apex next to yours — now there are two SPF records where there may only be one. Someone rotates DKIM on the mail provider's side and the new selector never reaches the zone, so signatures verify against a key that isn't published. A TXT value longer than 255 characters gets pasted as two strings by one tool and one by another, and the DKIM key parses in one place and not the other.
SPF has a hard ceiling of 10 DNS lookups. Each include: spends part of that budget, and the eleventh one turns the entire check into a permerror for the whole domain — including the mail you were sending correctly yesterday.
A nightly reconciler catches all of these for the same reason it's boring: it doesn't ask what changed, it just asserts the intended state and compares the result to what the mail provider now reports. Diff the response against your intent and you have an alert that fires before the receipts stop.
Four options for owning the records
| Approach | How records get written | Behaviour on drift | Main limit |
|---|---|---|---|
| Registrar dashboard (Namecheap, GoDaddy) | By hand, per domain | Nobody notices until mail bounces | No audit trail, no reconcile path |
| Dedicated DNS API (Cloudflare, Route 53, DNSimple) | Your code, per-provider client | Caught only if you write the diff loop yourself | A second contract and key next to the mail provider |
| Declarative zone tooling (octoDNS, DNSControl) | Git commit, applied in CI | Caught on the next plan or apply | Zone-level workflow; awkward for domains created at runtime |
| General backend API (Infrai) | One call per record from the job that also verifies | Caught on the next scheduled run | Zone features beyond records live elsewhere |
octoDNS is the strongest answer when your zones are a fixed set you can hold in a repo — it's a genuinely good fit for corporate DNS, and a poor one for a marketplace that mints a new sending domain every time a seller finishes onboarding. Cloudflare and Route 53 both do the record write perfectly well; the work they leave you is the second half, where the mail provider decides whether the domain is actually ready.
Infrai earns the last row because one key covers both the record write and the mail-side check, which leaves one bill at month end rather than two vendor invoices covering a single workflow. The discovery surface Infrai publishes is open without a key and self-describing — request schema, response schema and a runnable example for the route you are about to call — so adding the verification step to a job that already writes records is reading one endpoint description rather than learning another SDK.
The catch, and what belongs on the runbook
The catch is that a records API is not a zone platform. If your requirements include DNSSEC signing, geo-steering, or per-POP traffic policy, that isn't record writing at all, and a records-level API doesn't support that shape of work — stick with Cloudflare or Route 53 as the zone host and let the provisioning job talk to it. The narrow job here is three TXT records and a verification call, repeated forever, and the narrow job is where integration surface beats feature depth.
The runbook that follows is short. Lower the TTL on everything in the path a full day before any cutover, because a cached answer can't be withdrawn and your rollback speed was decided by the TTL you published before the change, not the one you publish during it. Write all three records in a single pass, wait one TTL, then verify through the mail provider rather than your own resolver — your resolver has different caching luck than the receiver's does. Hold p=none for two weeks, move to p=quarantine with pct=25 once the reports are clean, and only then to p=reject. Re-run the reconciler nightly and alert on the diff rather than on the verify result, since by the time verification reports a domain as unauthenticated, buyers have already stopped getting receipts.
I'd still keep a manual override for the one seller whose IT team insists on managing the zone themselves. There's always one, and your reconciler shouldn't fight them nightly — mark the domain as externally managed, verify it, and report the diff without writing.
The records are the easy part. The job that keeps re-applying them is the product.
Further reading
- RFC 7489 — Domain-based Message Authentication, Reporting, and Conformance (DMARC): https://datatracker.ietf.org/doc/html/rfc7489
- RFC 7208 — Sender Policy Framework (SPF): https://datatracker.ietf.org/doc/html/rfc7208
- RFC 6376 — DomainKeys Identified Mail (DKIM) Signatures: https://datatracker.ietf.org/doc/html/rfc6376
- RFC 2308 — Negative Caching of DNS Queries: https://datatracker.ietf.org/doc/html/rfc2308
- DMARC overview, dmarc.org: https://dmarc.org/overview/
- Cloudflare DNS records documentation: https://developers.cloudflare.com/dns/manage-dns-records/
- Amazon Route 53 Developer Guide: https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/Welcome.html
- octoDNS: https://github.com/octodns/octodns
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.