Welcome email deliverability checklist for SaaS, run as code

Nine pre-flight checks before the first welcome email leaves a custom sending domain: DKIM, DMARC, warm-up caps and suppression, each one a live Infrai API call.

A welcome email is the worst place to find out your sending domain was never authenticated. The checklist below has nine items, and on Infrai every one of them is an API call you can make before a single user signs up: register the domain, read the SPF/DKIM/DMARC records back, confirm the DNS checks passed, look at the warm-up cap, screen the address against your suppression list, send, then poll the delivery timeline.

Eight of those nine calls are free. Only the send itself is billable, so the whole pre-flight can run in CI on every deploy and cost nothing — which is the difference between a checklist people actually run and a wiki page nobody opens.

The nine checks and what proves each one

#CheckCall that proves itBillable
1The domain is registered on this accountGET /v1/email/domain/listfree
2SPF, DKIM, DMARC and tracking records were issuedPOST /v1/email/domain/verifyfree
3DNS actually resolves themGET /v1/email/domain/get/{domain}checksfree
4Domain status is verified, not pending_dnssame call → verification.statusfree
5Today’s send budget isn’t already spentsame call → reputation.used_todayfree
630-day bounce rate is under the next-tier thresholdsame call → reputation.bounce_rate_30dfree
7The recipient isn’t suppressedGET /v1/email/suppression/check/{email}free
8The message left the platformGET /v1/email/get/{id}free
9It was accepted by the receiving MTAGET /v1/email/event/listfree

Only step 2’s sibling — the actual POST /v1/email/send — costs anything.

Register the domain and take the records it hands you

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":"mail.example.com"}'

The response is the DNS work order. Four records, each tagged with its purpose, plus a recommended TTL:

{
  "ok": true,
  "data": {
    "domain": "mail.example.com",
    "domain_id": "dom_Arov0eH2udgqOcYTGu0MtvvB",
    "status": "pending_dns",
    "dns_records": [
      { "type": "TXT", "name": "mail.example.com", "value": "v=spf1 include:_spf.infrai.cc ~all", "purpose": "spf", "ttl_recommended": 3600 },
      { "type": "TXT", "name": "cf._domainkey.mail.example.com", "value": "v=DKIM1;k=rsa;p=MIIBIj...AB", "purpose": "dkim", "ttl_recommended": 3600 },
      { "type": "CNAME", "name": "track.mail.example.com", "value": "tracking.infrai.cc", "purpose": "tracking", "ttl_recommended": 3600 },
      { "type": "TXT", "name": "_dmarc.mail.example.com", "value": "v=DMARC1;p=none;rua=mailto:dmarc@infrai.cc", "purpose": "dmarc", "ttl_recommended": 3600 }
    ]
  }
}

Publish all four at your DNS host. The DMARC record ships as p=none on purpose — you want the aggregate reports flowing for a few weeks before you tighten to quarantine, and RFC 7489 is explicit that a policy you can’t yet measure is a policy that silently eats real mail.

Send from a subdomain, not your apex. If a burst of signups drags reputation down, you’d rather the damage sit on mail.example.com than on the domain your sales team emails from.

Read the state back before you trust it

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

Two blocks come back. verification.checks tells you which DNS lookups resolved; reputation tells you how much you’re allowed to send today:

{
  "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_requirements": { "bounce_rate_30d_lt": 0.02, "complaint_rate_30d_lt": 0.0005, "days_in_current_tier_gte": 30 },
      "throttle_risk": "low"
    }
  }
}

next_tier_requirements is the useful bit: a bounce rate at or above 2% keeps you in the warming tier, and welcome emails are exactly where bad addresses concentrate. Typo’d signups bounce hard.

Screen the address, every time

A suppression list only helps if you read it before you send.

curl -sS "https://api.infrai.cc/v1/email/suppression/check/user@example.com" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "email": "user@example.com",
    "reason": "manual",
    "added_at": "2026-07-04T17:02:22.803322Z",
    "scope": "account",
    "suppressed": true
  }
}

Hard bounces and complaints land there automatically; you add the rest with POST /v1/email/suppression/add, whose one required field is email (the reason is normalised on the way in, so don’t build logic on the value you sent). A send to a suppressed address isn’t rejected outright — the recipient comes back in suppressed_recipients and no mail goes out.

The pre-flight script

This is the whole checklist in one file. Node 22, no dependencies, exits non-zero so CI stops the deploy.

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) {
  const res = await fetch(API + path, {
    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 domain = process.env.SENDING_DOMAIN ?? "mail.example.com";
const recipient = process.argv[2] ?? "user@example.com";
const failures = [];

const { verification, reputation } = await call(`/v1/email/domain/get/${domain}`);

if (verification.status !== "verified") {
  failures.push(`domain status is ${verification.status}`);
}
for (const [name, state] of Object.entries(verification.checks ?? {})) {
  if (state !== "verified") failures.push(`DNS check ${name} is ${state}`);
}
if (reputation.bounce_rate_30d >= 0.02) {
  failures.push(`bounce rate ${(reputation.bounce_rate_30d * 100).toFixed(2)}% blocks the next tier`);
}
const headroom = reputation.current_daily_cap - reputation.used_today;
if (headroom <= 0) failures.push(`daily cap ${reputation.current_daily_cap} already spent`);

const supp = await call(`/v1/email/suppression/check/${encodeURIComponent(recipient)}`);
if (supp.suppressed) failures.push(`${recipient} is suppressed (${supp.reason})`);

if (failures.length) {
  console.error("welcome-email pre-flight FAILED:");
  for (const f of failures) console.error("  - " + f);
  process.exit(1);
}
console.log(`pre-flight ok: ${domain}, ${headroom} sends left today`);

Send it, then prove it landed

curl -sS -X POST "https://api.infrai.cc/v1/email/send" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"to":"user@example.com","from":"welcome@mail.example.com","subject":"Welcome to Acme","html":"<p>Your workspace is ready.</p>"}'

Keep the returned message_id. The timeline is a separate free call, and it’s the one that tells you whether the receiving server took the message or refused it:

curl -sS "https://api.infrai.cc/v1/email/event/list?message_id=msg_DgOWYJSuArAxcSI9MCzYLSJp" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Events arrive newest-first with type, recipient and at; queued then sent is the healthy pair, and bounced carries the reason you’ll want in your logs. For a single-state answer instead of a timeline, GET /v1/email/get/{id} is cheaper to poll.

What a send costs, and how to check today’s number

Sending is the only billable step here: $0.000115 per email, per recipient, verified 2026-07-25. New accounts start with a $2 credit, which works out at roughly 17,391 emails before you pay anything. Every domain, suppression and event call above is free and rate-limited instead of metered.

Rates move, and they mostly move down as vendor discounts land, so read it yourself rather than trusting this paragraph:

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([(c['id'], c['billing']) for c in d['capabilities'] if c['id'] == 'email.send'])"

Limitations, and when to use something else

Custom sender domains are a paid-plan feature. On a standard account POST /v1/email/domain/verify answers HTTP 402 with PRO_REQUIRED, and until you upgrade you’re sending from the shared domain — fine for a staging environment, wrong for a welcome email that should look like it came from you.

The delivery vendor behind Infrai’s western region is Resend today, with Amazon SES and Tencent listed as pending rather than ready. That’s an honest trade-off: you get one credential and one bill across email, SMS, storage, cron and the rest, but you don’t get to pick the underlying MTA per send yet.

If deliverability consulting is the product you’re shopping for — seed-list testing, inbox placement reporting, a human who reads your DMARC aggregates — stick with Postmark, whose whole positioning is transactional reputation. If email is the only external service your app will ever call, a single-vendor account is simpler than a platform account and you should take it.

The argument for doing it here is the second question. The welcome email is never the whole job: something has to schedule the 3-day nudge, store the rendered HTML, catch the error when DNS breaks, and attribute the cost to a tenant. Those are the same key and the same invoice, not four more vendor onboardings.

References

Browse more email developer guides