SPF, DKIM and DMARC for a Node transactional sender
What each of the four DNS records does, what breaks when it's missing, and a Node 22 script that reconciles published DNS against what the sending API expects.
Authenticating a sending domain is four DNS records and about twenty minutes, and the reason it feels harder than that is that nothing tells you which one you got wrong. A misaligned SPF include and a missing DKIM key produce the same user-visible symptom — mail lands in spam, or nowhere. Infrai’s approach is to hand you the exact records to publish and then expose a per-record checks map you can assert on from Node, which turns “is our email set up right” into a test rather than an argument.
This walks through what each record is for, then the reconciliation script we’d run in CI. Node 22, no dependencies, using node:dns against the live zone.
The four records and what each one buys
| Record | Type | Name | What breaks without it |
|---|---|---|---|
| SPF | TXT | the sending domain itself | Receivers can’t confirm the sending IP is authorised; DMARC has one fewer way to pass |
| DKIM | TXT | cf._domainkey.<domain> | Messages are unsigned; forwarding and any body modification become indistinguishable from forgery |
| DMARC | TXT | _dmarc.<domain> | No policy and no aggregate reports; bulk receivers increasingly treat that as a negative signal |
| Tracking | CNAME | track.<domain> | Open and click links stay on a shared host, which weakens brand alignment in the URL |
DMARC passes if either SPF or DKIM passes and the passing identifier aligns with the visible From domain. That “and aligns” clause is where most setups quietly fail: an SPF record that authorises your ESP but a bounce-return path on the ESP’s own domain gives you an SPF pass with no alignment, and DKIM ends up carrying the whole thing on its own.
Start the registration and the API returns the exact values:
export INFRAI_API_KEY="your_infrai_api_key"
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":"notify.example.com"}'
Publish what comes back, then call the same route again — it’s idempotent, and the status moves from pending_dns to verified once the lookups resolve.
Check the zone before you check the API
DNS is the layer that lies to you, usually via a cached TTL or a provider that silently appends its own suffix to a TXT name.
dig +short TXT notify.example.com
dig +short TXT cf._domainkey.notify.example.com
dig +short TXT _dmarc.notify.example.com
dig +short CNAME track.notify.example.com
A common failure here has nothing to do with the values: SPF permits a maximum of 10 DNS-resolving mechanisms per evaluation, and RFC 7208 says an evaluator that exceeds it must return permerror. Stack three include: statements from three vendors and you can blow the budget without any single record looking wrong.
Reconciling published DNS against what the API expects
import { resolveTxt, resolveCname } from "node:dns/promises";
import process from "node:process";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const domain = process.argv[2] ?? "mail.example.com";
const res = await fetch(`${API}/v1/email/domain/get/${domain}`, {
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
});
const payload = await res.json();
if (!res.ok || payload.ok === false) {
const e = payload.error ?? { code: `HTTP_${res.status}`, message: res.statusText };
throw new Error(`domain get -> ${e.code}: ${e.message}`);
}
const expected = payload.data.verification.dns_records;
const problems = [];
for (const rec of expected) {
try {
if (rec.type === "TXT") {
const chunks = await resolveTxt(rec.name);
const published = chunks.map((parts) => parts.join(""));
const marker = rec.value.split(";")[0];
if (!published.some((v) => v.startsWith(marker))) {
problems.push(`${rec.purpose}: no TXT at ${rec.name} starting "${marker}"`);
}
} else if (rec.type === "CNAME") {
const targets = await resolveCname(rec.name);
if (!targets.includes(rec.value)) {
problems.push(`${rec.purpose}: ${rec.name} points at ${targets.join(",") || "nothing"}`);
}
}
} catch (err) {
problems.push(`${rec.purpose}: lookup of ${rec.name} failed (${err.code ?? err.message})`);
}
}
for (const [check, state] of Object.entries(payload.data.verification.checks ?? {})) {
if (state !== "verified") problems.push(`api check ${check} = ${state}`);
}
if (problems.length) {
console.error(`${domain} is not correctly authenticated:`);
for (const p of problems) console.error(" - " + p);
process.exit(1);
}
console.log(`${domain}: SPF, DKIM, DMARC and tracking all resolve`);
Comparing only the prefix before the first semicolon is deliberate. DKIM public keys are long, chunked differently by different resolvers, and rotate; asserting on v=DKIM1 tells you a key is published without making the test brittle every time one changes.
The API’s own view is the second half of the same question:
curl -sS "https://api.infrai.cc/v1/email/domain/get/mail.example.com" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"checks": {
"spf_dns": "verified",
"dkim_dns": "verified",
"tracking_cname": "verified",
"dmarc_dns": "verified",
"mail_loopback": "verified"
}
}
mail_loopback is the one worth watching. It means a real message went out and came back — the closest thing to an end-to-end proof any of these checks give you.
Tightening DMARC, on your own schedule
The _dmarc record ships as p=none, which reports without enforcing. That’s correct for week one and wrong for month six. Move it yourself in your DNS: p=quarantine; pct=25 for a couple of weeks, watch the aggregate reports for legitimate senders you forgot about (the CRM, the invoicing tool, the ticketing system), then widen the percentage and finally go to p=reject.
Re-run GET /v1/email/domain/get/{domain} after each edit and confirm dmarc_dns still reads verified. Mailgun’s write-up on the three records is a decent second opinion on the ordering if you want one.
Sending and polling from Node
import { setTimeout as sleep } from "node:timers/promises";
import process from "node:process";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
async function call(path, init = {}) {
const res = await fetch(API + path, {
...init,
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
});
const payload = await res.json();
if (!res.ok || payload.ok === false) {
const e = payload.error ?? { code: `HTTP_${res.status}`, message: res.statusText };
throw new Error(`${path} -> ${e.code}: ${e.message}`);
}
return payload.data;
}
const sent = await call("/v1/email/send", {
method: "POST",
body: JSON.stringify({
to: "customer@example.com",
from: "receipts@mail.example.com",
subject: "Receipt for order 8812",
html: "<p>Thanks — your card was charged $19.00.</p>",
}),
});
if (sent.suppressed_recipients?.length) {
console.warn("suppressed:", sent.suppressed_recipients.join(", "));
process.exit(0);
}
let state = "queued";
for (let attempt = 0; attempt < 6 && state === "queued"; attempt++) {
await sleep(2000 * 2 ** attempt);
({ state } = await call(`/v1/email/get/${sent.message_id}`));
}
console.log(`${sent.message_id} -> ${state}`);
Exponential backoff, six attempts, roughly two minutes of total patience. Anything still queued after that is a throttle question rather than a delivery question, and reputation.current_daily_cap is where you look.
For the full per-recipient trail, including bounces:
curl -sS "https://api.infrai.cc/v1/email/event/list?message_id=msg_DgOWYJSuArAxcSI9MCzYLSJp" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Bounced addresses are added to the suppression list automatically; GET /v1/email/suppression/list reads it back, and future sends to those addresses return them under suppressed_recipients instead of delivering.
Cost and the caveats
Domain verification, DNS checks, message reads, event listing and suppression are free and rate-limited rather than metered. Sends cost $0.000115 per recipient, verified 2026-07-26, and a new account starts with a $2 credit — roughly 17,391 messages. Rates tend to fall as upstream discounts arrive, so read the live figure:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print(next(c['billing'] for c in d['capabilities'] if c['id']=='email.send'))"
Two caveats before you commit. Custom sending domains need a paid plan — a standard account gets HTTP 402 PRO_REQUIRED back from the verify route. And the DKIM selector is fixed at cf._domainkey, so rotation replaces the record in place rather than running two selectors in parallel; if you need overlapping keys during a rotation window, Mailgun and Amazon SES both handle that differently and you’d be better off checking their behaviour against your security policy.
What you get in exchange is that the rest of the job is already on this key. The receipt PDF goes to object storage, the nightly DNS assertion runs on the platform’s cron, the failed send raises an error event, and all of it lands on one bill with per-tenant attribution as a query — instead of four vendor accounts to reconcile at the end of the month.