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 part | Self-hosted Postfix | Amazon SES | Infrai |
|---|---|---|---|
| IP reputation | Yours to build, from zero | Shared pool, or a dedicated IP you warm | Platform pool, warm-up state exposed per domain |
| Sending domain auth | You generate and publish keys | Console flow, then DNS | POST /v1/email/domain/verify returns the records |
| Bounce handling | Parse the bounce mailbox yourself | SNS topic, then your consumer | Automatic; readable via suppression list |
| Complaint feedback loops | Register with each ISP | Configured per identity | Automatic; folded into bounce_rate_30d and complaint tracking |
| Volume ramp | Your own throttle logic | Sending quota that rises with good behaviour | daily_limit_current climbing toward daily_limit_target |
| Ongoing cost floor | A server, plus your time | Per message, plus infrastructure | Per 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.