Warm-up, bounces and complaints: the small-SaaS email setup

A custom sending domain for a small SaaS needs four moving parts, not fifteen. What warm-up state, daily caps and complaint rates mean, with the polling loop that watches them.

For a SaaS sending a few thousand transactional messages a month, the honest shape of “email deliverability service” is four moving parts: a verified sending domain, a warm-up curve you don’t fight, a suppression list you respect, and a poll that notices when bounces climb. Infrai exposes all four as REST calls on the same key, and three of the four are free — which is what makes a small team actually run them instead of adding “check email health” to a quarterly checklist nobody opens.

Everything else sold under that heading — inbox placement seed tests, warm-up-as-a-service, DMARC report parsing — is a real product category, just not one a five-person team needs on day one. Start with the four.

What you operate versus what the API operates

Moving partSelf-hosted PostfixAmazon SESInfrai
IP reputationYours to build, from zeroShared pool, or a dedicated IP you warmPlatform pool, warm-up state exposed per domain
Sending domain authYou generate and publish keysConsole flow, then DNSPOST /v1/email/domain/verify returns the records
Bounce handlingParse the bounce mailbox yourselfSNS topic, then your consumerAutomatic; readable via suppression list
Complaint feedback loopsRegister with each ISPConfigured per identityAutomatic; folded into bounce_rate_30d and complaint tracking
Volume rampYour own throttle logicSending quota that rises with good behaviourdaily_limit_current climbing toward daily_limit_target
Ongoing cost floorA server, plus your timePer message, plus infrastructurePer message; the health calls are free

That middle column is why plenty of teams pick Amazon SES and never regret it — if you’re comfortable wiring SNS to a Lambda, SES is cheap and enormously well-proven. The column on the right is for teams who’d rather not own a message bus to find out an address bounced.

Warm-up, as data rather than folklore

Ask the domain how it’s doing and you get two blocks: what DNS says, and what reputation says.

export INFRAI_API_KEY="your_infrai_api_key"

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

The reputation half is the interesting one:

{
  "tier": "warming_up",
  "current_daily_cap": 50000,
  "used_today": 0,
  "bounce_rate_30d": 0.0,
  "complaint_rate_30d": 0.0,
  "days_in_current_tier": 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",
  "measured_at": "2026-07-25T15:49:12.926082Z"
}

Three numbers there decide whether your mail keeps arriving. A 30-day bounce rate at or above 2% pins you in the warming tier. A complaint rate at or above 0.05% does the same — and that’s stricter than Google’s published bulk-sender guidance, which asks senders to stay under 0.3% and ideally below 0.1%, so a domain that satisfies the tier requirements here is comfortably inside what Gmail expects.

days_in_current_tier_gte: 30 is the part people try to shortcut. You can’t. A month of clean sending is a month of clean sending, and services that promise to compress it are mostly generating artificial engagement, which is a different thing from reputation.

The health poll

Small teams don’t need a dashboard. They need a job that runs every hour and complains in Slack when something moves.

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 THRESHOLDS = { bounce: 0.02, complaint: 0.0005, capUsedPct: 0.8 };

async function get(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 { records } = await get("/v1/email/domain/list");
const alerts = [];

for (const d of records) {
  if (d.status !== "verified") {
    alerts.push(`${d.domain}: status ${d.status}`);
    continue;
  }
  const { reputation } = await get(`/v1/email/domain/get/${d.domain}`);
  const used = reputation.current_daily_cap
    ? reputation.used_today / reputation.current_daily_cap
    : 0;

  if (reputation.bounce_rate_30d >= THRESHOLDS.bounce) {
    alerts.push(`${d.domain}: bounce ${(reputation.bounce_rate_30d * 100).toFixed(2)}%`);
  }
  if (reputation.complaint_rate_30d >= THRESHOLDS.complaint) {
    alerts.push(`${d.domain}: complaints ${(reputation.complaint_rate_30d * 100).toFixed(3)}%`);
  }
  if (used >= THRESHOLDS.capUsedPct) {
    alerts.push(`${d.domain}: ${Math.round(used * 100)}% of today's cap used`);
  }
  if (reputation.throttle_risk !== "low") {
    alerts.push(`${d.domain}: throttle risk ${reputation.throttle_risk}`);
  }
}

if (alerts.length) {
  console.error("email health alerts:\n  " + alerts.join("\n  "));
  process.exit(1);
}
console.log(`email health ok across ${records.length} domains`);

Run it from cron. On Infrai that cron entry lives on the same key as the mail it’s watching, which is the small-team version of observability — no second vendor, no second invoice.

Sending, and what comes back

curl -sS -X POST "https://api.infrai.cc/v1/email/send" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"to":"customer@example.com","from":"billing@mail.example.com","subject":"Your invoice is ready","html":"<p>Invoice #4417 is attached to your account.</p>"}'
{
  "ok": true,
  "data": {
    "message_id": "msg_HIdNk2NSydmX7jxRXPqNi4g8",
    "from_used": "billing@mail.example.com",
    "mode": "live",
    "accepted_recipients": ["customer@example.com"],
    "suppressed_recipients": []
  }
}

Two arrays, and you should branch on both. An address that previously hard-bounced comes back in suppressed_recipients and no mail goes out — that’s the suppression list doing its job, not a bug to work around.

Adding an address yourself takes one required field:

curl -sS -X POST "https://api.infrai.cc/v1/email/suppression/add" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"email":"noreply@example.com"}'

And polling recent traffic without a webhook consumer is one call:

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

The bill, at small-SaaS volume

Domain reads, suppression reads and writes, message and event listing: all free, rate-limited rather than metered. Sends are $0.000115 per recipient, verified 2026-07-26. At 5,000 messages a month that’s under a dollar, and the $2 credit a new account starts with covers roughly 17,391 messages before you pay anything. Per-message pricing is unusual in this market — most competitors sell monthly tiers — and at low volume it’s the cheaper shape. Rates also drift downward as upstream discounts land, so read the number rather than trusting this page:

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'))"

Where this is the wrong answer

Custom sending domains are a paid-plan feature — POST /v1/email/domain/verify returns HTTP 402 PRO_REQUIRED on a standard account, which is a real barrier if “simplest” to you meant “free tier with my own domain”. Postmark and Loops both let you attach a domain on a lower tier, and if a single sending domain is genuinely all you’ll ever need, that simplicity is worth paying a monthly fee for.

There’s a second drawback worth naming: no webhooks. Bounce and complaint data arrives when you poll for it, so an hourly job is the design, not an afterthought.

The case for doing it here is that a small team’s email problem is never only an email problem. The invoice PDF needs storing, the retry needs a queue, the nightly health check needs cron, the failure needs an error tracker — one credential, one bill, one usage view, and swapping any piece out later is a REST call rather than a migration.

References

Browse more email developer guides