SaaS alert emails: give them their own sending subdomain
Build event-alert email for a Node SaaS: a dedicated alerts subdomain with DKIM, one stored template, and flood control that runs before the send.
Alert mail behaves nothing like the rest of your outbound. It arrives in bursts, it goes to internal addresses that churn, and when something is genuinely broken you’ll send a thousand of them in an hour. Put that traffic on alerts.yourdomain.com with its own DKIM key, keep one stored template on Infrai, and do the deduplication before you call send — not in the recipient’s inbox rules.
Everything below is a working path from an empty account to an alert that lands, with the deliverability parts made concrete instead of hand-waved. Infrai’s email routes are used throughout because the sender, the template and the delivery evidence all sit on one key.
Why alerts deserve their own subdomain
Sender reputation is measured per domain, and alert traffic is the worst-behaved mail you own: sudden volume, high bounce rates from ex-employees’ addresses, and recipients who mark noisy alerts as spam rather than unsubscribing. If that shares a domain with your password resets, a bad week for alerts is a bad week for logins.
Split them, and the blast radius stops at the subdomain.
| Sender choice | Setup cost | Reputation risk to logins and receipts | Good for |
|---|---|---|---|
Shared send.infrai.cc sender | None, works immediately | None to you — but the From: isn’t yours | Staging, first prototype |
| Alerts on your main domain | Reuses existing DNS | High: one alert storm hits reset mail | Very low alert volume |
alerts.yourdomain.com | Four DNS records, Pro plan | Contained to alert traffic | Any production SaaS |
Infrai exposes reputation as data rather than a dashboard graph, which makes the split measurable. GET /v1/email/domain/get/{domain} returns both the DNS verification state and a reputation block:
curl -sS https://api.infrai.cc/v1/email/domain/get/alerts.example.net \
-H "Authorization: Bearer ${INFRAI_API_KEY:?export it as your_infrai_api_key}"
{
"ok": true,
"data": {
"verification": {
"status": "verified",
"checks": {"spf_dns": "verified", "dkim_dns": "verified", "tracking_cname": "verified", "dmarc_dns": "verified", "mail_loopback": "verified"},
"warm_up_state": "in_progress",
"daily_limit_current": 50000,
"daily_limit_target": 500000
},
"reputation": {
"tier": "warming_up",
"current_daily_cap": 50000,
"used_today": 0,
"bounce_rate_30d": 0.0,
"next_tier": "established",
"next_tier_requirements": {"bounce_rate_30d_lt": 0.02, "complaint_rate_30d_lt": 0.0005, "days_in_current_tier_gte": 30},
"throttle_risk": "low"
}
}
}
Those thresholds are the argument in numeric form. A bounce rate above 2% keeps a domain out of the next tier, and alert mail to stale internal addresses is exactly how a bounce rate climbs. Isolate it.
Setting the subdomain up
Registering a sender domain is one call, and it returns the records to publish rather than making you copy them out of a web console:
curl -sS -X POST https://api.infrai.cc/v1/email/domain/verify \
-H "Authorization: Bearer ${INFRAI_API_KEY:?export it as your_infrai_api_key}" \
-H "Content-Type: application/json" \
-d '{"domain": "alerts.example.net"}'
Four entries come back: SPF as a TXT at the subdomain apex (v=spf1 include:_spf.infrai.cc ~all), DKIM as a TXT at cf._domainkey.alerts.example.net, a tracking CNAME, and a DMARC TXT at _dmarc.alerts.example.net starting at p=none. Publish all four at the recommended 3600-second TTL, then call verify again until status flips to verified; the checks map tells you which single record is still failing, which beats guessing.
Worth flagging before you plan around this: custom sender domains are a Pro feature, and on a standard key that verify call returns 402 PRO_REQUIRED. Until you upgrade, alerts go out from the shared send.infrai.cc sender, which is fine for a staging environment and wrong for anything a customer sees.
One template, severity as data
The renderer substitutes {{var}} and nothing else — no conditionals — so don’t try to branch on severity inside the HTML. Pass the badge text in as a variable instead, and let your code decide what it says.
curl -sS -X POST https://api.infrai.cc/v1/email/template/create \
-H "Authorization: Bearer ${INFRAI_API_KEY:?export it as your_infrai_api_key}" \
-H "Content-Type: application/json" \
-d '{
"name": "alert-2026",
"subject": "[{{severity}}] {{title}}",
"html": "<p><strong>{{severity}}</strong> - {{title}}</p><p>{{summary}}</p><p>Service: {{service}} | First seen: {{first_seen}} | Occurrences: {{count}}</p><p><a href=\"{{console_url}}\">Open in console</a></p>",
"body_text": "[{{severity}}] {{title}} - {{summary}} ({{service}}, {{count}} occurrences since {{first_seen}}) {{console_url}}",
"variables": ["severity", "title", "summary", "service", "first_seen", "count", "console_url"],
"default_vars": {"severity": "WARN"},
"tags": ["alerts"]
}'
Preview it against real-looking values before it ever fires at 3am:
curl -sS -X POST \
https://api.infrai.cc/v1/email/template/preview/tmpl_9KEpzV26D74V1iGA3Y5vvl7P \
-H "Authorization: Bearer ${INFRAI_API_KEY:?export it as your_infrai_api_key}" \
-H "Content-Type: application/json" \
-d '{"vars": {"title": "Checkout error rate above 5%", "summary": "127 failed checkouts in the last 10 minutes.", "service": "payments-api", "first_seen": "2026-07-26T05:31:00Z", "count": "127", "console_url": "https://app.example.net/alerts/a_44f1"}}'
The rendered subject comes back as [WARN] Checkout error rate above 5% — severity was never supplied, and default_vars filled it. That default is a safety net, not a feature to rely on: an alert that silently claims WARN when your code meant CRITICAL is worse than one that fails loudly.
Flood control before the send, not after
The single most useful thing you can build here is a suppression window keyed on the alert’s fingerprint. One email per fingerprint per window, with an occurrence count in the body — that’s why count is a template variable.
// alert-mailer.mjs — Node 22 ESM. One email per fingerprint per cooldown window.
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("set INFRAI_API_KEY (your_infrai_api_key)");
const TEMPLATE = "tmpl_9KEpzV26D74V1iGA3Y5vvl7P";
const COOLDOWN_MS = 15 * 60 * 1000;
const SEVERITY = { 1: "CRITICAL", 2: "ERROR", 3: "WARN" };
// Replace with Redis or a table; the semantics are what matter.
const window = new Map(); // fingerprint -> { firstSeen, count, notifiedAt }
async function send(body) {
const res = await fetch(`${API}/v1/email/send`, {
method: "POST",
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: JSON.stringify(body),
});
const payload = await res.json();
if (!res.ok || payload.ok === false) {
throw new Error(`alert send failed: ${payload?.error?.code ?? res.status}`);
}
return payload.data;
}
export async function raise({ fingerprint, level, title, summary, service, to }) {
const now = Date.now();
const state = window.get(fingerprint) ?? { firstSeen: now, count: 0, notifiedAt: 0 };
state.count += 1;
window.set(fingerprint, state);
if (now - state.notifiedAt < COOLDOWN_MS) return { suppressed: true, count: state.count };
state.notifiedAt = now;
const data = await send({
to,
template_id: TEMPLATE,
template_vars: {
severity: SEVERITY[level] ?? "WARN",
title,
summary,
service,
first_seen: new Date(state.firstSeen).toISOString(),
count: String(state.count),
console_url: `https://app.example.net/alerts/${fingerprint}`,
},
});
return { suppressed: false, message_id: data.message_id, count: state.count };
}
console.log(await raise({
fingerprint: "a_44f1",
level: 2,
title: "Checkout error rate above 5%",
summary: "127 failed checkouts in the last 10 minutes.",
service: "payments-api",
to: "oncall@example.net",
}));
Two things that script does on purpose. It never retries a failed send blindly, because the send route accepts an idempotency_key without deduplicating on it — identical bodies produce distinct messages and separate charges, so a retry loop around alert mail is a way to bill yourself for a storm. And it keeps counting during the cooldown, so the next email says 400 occurrences rather than pretending the incident restarted.
Did the alert actually land?
curl -sS "https://api.infrai.cc/v1/email/event/list?message_id=msg_FjDRVM4y1dx7xcElLubSlJMF" \
-H "Authorization: Bearer ${INFRAI_API_KEY:?export it as your_infrai_api_key}"
Events come back newest-first with type, at, recipient and the vendor’s own id — queued, sent, then delivered or bounced. There are no webhooks in this namespace, so on-call tooling polls. For alerting that’s usually the right trade-off anyway; a webhook receiver is one more thing to be down during an incident.
Cost, and when email isn’t the answer
Sends are $0.000115 each, verified 2026-07-26; domain, template and event calls are free and rate-limited. A team producing 200 alert emails a day spends about $0.70 a month, and the $2 of trial credit new accounts get covers roughly 17,391 messages.
curl -sS https://api.infrai.cc/v1/discovery \
-H "Authorization: Bearer ${INFRAI_API_KEY:?export it as your_infrai_api_key}" \
| python3 -c "import sys,json;d=json.load(sys.stdin);print([(c['id'],(c.get('billing') or {}).get('price_usd'),(c.get('billing') or {}).get('unit')) for c in d['capabilities'] if c['namespace']=='email'])"
Rates here drift down over time and promotions run against them, so read the live figure rather than budgeting from this paragraph. Cost is not the reason to choose this route — at these volumes every option is rounding error.
The reason is the seam. Postmark’s separate message streams are a better-designed answer if alert email is all you need and you want stream-level analytics, and SendGrid gives you deeper reporting. What neither gives you is the queue that buffers the alert storm, the object storage holding the diagnostic bundle you linked to, and the SMS route you escalate to after ten unacknowledged minutes — all on the credential you already used above, on one invoice.