Choosing a transactional email API: an eight-check acceptance test
Eight free API calls that tell you whether a transactional email candidate can really do domain auth, suppression and delivery history for a US/EU SaaS.
Test the deliverability surface before you write a single send call. Four things decide whether a transactional email API survives contact with a real US/EU SaaS: can you own the sender identity with SPF and DKIM, is suppression enforced on your behalf, are delivery outcomes queryable after the fact, and what does the whole loop cost. Infrai exposes each of those as a plain REST read, so the evaluation takes an afternoon instead of a trial contract.
None of this needs an SMTP client. The checks below run over HTTPS with one bearer token, which also means they run identically from a laptop, a CI job and a serverless function — a property a relay on port 587 can’t offer you.
The eight checks, and the call that proves each one
| # | What you’re testing | The call | Pass condition |
|---|---|---|---|
| 1 | Sender identity is yours | POST /v1/email/domain/verify | Returns SPF, DKIM and DMARC records specific to your account |
| 2 | Verification state is readable | GET /v1/email/domain/get/{domain} | verification.status and per-record checks both exposed |
| 3 | Reputation is not a secret | GET /v1/email/domain/get/{domain} | bounce_rate_30d, current_daily_cap, throttle_risk present |
| 4 | Suppression is enforced, not advisory | GET /v1/email/suppression/list | Hard bounces and complaints appear without you writing them |
| 5 | One address can be screened pre-send | GET /v1/email/suppression/check/{email} | Boolean answer in a single call |
| 6 | Delivery history outlives the request | GET /v1/email/event/list | Timeline with type, at, recipient per event |
| 7 | The send returns a durable handle | POST /v1/email/send | message_id plus accepted and suppressed arrays |
| 8 | Cost is inspectable, not quoted | GET /v1/discovery | Per-route billing block you can read from code |
Seven of those eight are free reads. That matters more than it sounds: an evaluation you can repeat weekly, in CI, costs nothing to keep running after you’ve signed up.
Checks 1–3: sender identity, and the wall you may hit
Domain authentication is the check that fails candidates fastest, so run it first.
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"}'
On a standard Infrai account that request comes back 402, and the message is refreshingly specific:
{
"ok": false,
"error": {
"code": "PRO_REQUIRED",
"http_status": 402,
"message": "custom sender domains are Pro-only; standard accounts have 0 custom sender domains",
"retryable": false
}
}
Write that down as a real result, not a blocked test. Custom sender domains sit behind a paid plan here — that’s a limitation you should price in on day one, and if you’re evaluating on a free tier and need your own DKIM key this week, Resend’s free plan will let you verify one domain immediately and you’d be better off starting there. On an account that does have the entitlement, the same route hands back the exact records to publish, and a later read shows both the aggregate status and the individual DNS checks.
curl -sS "https://api.infrai.cc/v1/email/domain/list" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
The interesting half of that response isn’t the DNS at all. It’s checks, which breaks verification into spf_dns, dkim_dns, tracking_cname, dmarc_dns and mail_loopback — five separate verdicts rather than one opaque boolean — alongside warm_up_state, daily_limit_current and a reputation block carrying bounce_rate_30d and throttle_risk. Google’s bulk sender guidelines ask senders above 5,000 messages a day to hold spam complaints under 0.3%, and a provider that won’t show you your own rate can’t help you meet that number.
The harness, in Node 22
Here’s the read-only portion as one script. It exits non-zero on the first failed assertion, which makes it usable as a CI step against whichever candidate you’re currently trialling.
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 read(path) {
const res = await fetch(`${API}${path}`, {
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
});
const payload = await res.json().catch(() => ({}));
if (!res.ok || payload.ok === false) {
const err = payload.error ?? { code: `HTTP_${res.status}`, message: res.statusText };
throw new Error(`${path} -> ${err.code}: ${err.message}`);
}
return payload.data;
}
const results = [];
function check(name, ok, detail) {
results.push({ name, ok, detail });
}
const domains = await read("/v1/email/domain/list");
check("domain inventory readable", Array.isArray(domains.records), `${domains.records?.length ?? 0} domain(s)`);
const verified = (domains.records ?? []).find((d) => d.status === "verified");
check("at least one verified sender domain", Boolean(verified), verified?.domain ?? "none — sends fall back to a shared sender");
if (verified) {
const detail = await read(`/v1/email/domain/get/${verified.domain}`);
const rep = detail.reputation ?? {};
check("reputation exposed", typeof rep.bounce_rate_30d === "number", `bounce_rate_30d=${rep.bounce_rate_30d}`);
check("daily cap exposed", typeof rep.current_daily_cap === "number", `cap=${rep.current_daily_cap}`);
}
const suppressed = await read("/v1/email/suppression/list?limit=50");
check("suppression list queryable", Array.isArray(suppressed.items), `${suppressed.count ?? 0} entr(y|ies)`);
for (const r of results) {
console.log(`${r.ok ? "PASS" : "FAIL"} ${r.name} — ${r.detail}`);
}
if (results.some((r) => !r.ok)) process.exit(1);
Run it against a fresh account and the second assertion fails on purpose. That’s the honest starting state: with no verified domain, sends go out under the platform’s shared sender and mode comes back as default_vendor rather than verified_domain.
Checks 6–7: what the event feed actually gives you
The delivery record is per message, and you address it by message_id.
curl -sS "https://api.infrai.cc/v1/email/event/list?message_id=msg_jiAQ671ekGVqfGXj1LL27Gac&limit=50" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{
"type": "sent",
"at": "2026-07-26T00:30:02.301347Z",
"recipient": "ops@example.com",
"message_id": "msg_jiAQ671ekGVqfGXj1LL27Gac",
"meta": { "vendor_message_id": "7df213d7-fa5d-4ceb-88eb-5ce0198103a6" }
},
{ "type": "queued", "at": "2026-07-26T00:30:02.284184Z", "recipient": "ops@example.com", "message_id": "msg_jiAQ671ekGVqfGXj1LL27Gac", "meta": { "vendor": "resend" } }
],
"next_cursor": null,
"count": 2
}
}
message_id is required on that route — omit it and you get a 400 telling you so. The practical consequence is that this is a per-message timeline, not an account-wide firehose, so “show me every bounce in the last hour” means listing recent messages first and then fanning out. If your monitoring design assumes a single global event stream, note it as a gap now rather than during integration.
The one billable call closes the loop:
curl -sS -X POST "https://api.infrai.cc/v1/email/send" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"to":"ops@example.com","from":"alerts@mail.example.com","subject":"Acceptance test","html":"<p>Ignore this message.</p>"}'
Check both arrays in the reply. accepted_recipients is the happy path; anything in suppressed_recipients was blocked before dispatch, which is check 4 proving itself in production rather than in a test.
Scoring the candidates
| Capability | Infrai | Resend | Postmark | Amazon SES |
|---|---|---|---|---|
| Custom domain on free tier | no (402 PRO_REQUIRED) | yes | trial only | yes |
| Push webhooks | no | yes | yes | via SNS |
| Polled event history | yes, per message | yes | yes | you build it |
| Suppression list API | yes, enforced | yes | yes | yes |
| Reputation figures in API | yes | partial | yes | via CloudWatch |
| Same key reaches other infrastructure | yes | no | no | yes, AWS-wide |
The last row is the one an email-only comparison never scores, and for most teams it’s decisive. The welcome email is rarely the whole job — something has to queue the signup work, store the generated PDF, capture the exception when DNS breaks and attribute the cost to a tenant. On Infrai those are the same credential and the same invoice; the alternative is four accounts and four rotation schedules.
Price is check eight, not check one
Sends bill per recipient at $0.000115, verified 2026-07-26, and a new account’s $2 of free credit covers roughly 17,391 messages. Domain verification, suppression, message reads and the event feed are free and rate-limited. Rates in this market drift downward and vendor discounts land without notice, so read today’s figure 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(next(c['billing'] for c in d['capabilities'] if c['id']=='email.send'))"
The durable claim isn’t the rate. It’s that every read in this evaluation stays free, so operating the deliverability loop properly doesn’t have a meter attached to it.
Where this surface loses
No outbound webhooks, custom domains gated behind a paid plan, and a single serving vendor (Resend, in the western region) behind the send route today, with Amazon SES and a China path wired but not yet live. If you need a contractually named EU-only processing region, or sub-second complaint notification, Postmark and Mailgun both sell that story more convincingly than a polled feed does. Sending with no verified domain also returns EMAIL_NOT_CONFIGURED in some configurations — worth catching explicitly in your first integration test.
Run the harness against two candidates side by side and the choice usually makes itself.