Notification email never arrived: an API-driven triage runbook
Four questions in order — accepted, sent, bounced, or blocked at the domain — each answered by one free Infrai call, with a triage script that prints the verdict.
“The alert email didn’t arrive” has four possible answers and they need diagnosing in order: the platform never accepted the message, it accepted but never sent, it sent and the receiver refused it, or it went out fine and the recipient’s inbox filed it somewhere you can’t see. Infrai answers the first three with free API calls that take a message_id; the fourth is the one nobody’s API can answer, and pretending otherwise is how support tickets go round in circles.
Work down the list rather than jumping straight to DKIM. Most notification failures we see in testing are suppression hits and typo’d addresses, not authentication — authentication tends to fail for every message at once, which is a very different symptom shape.
Symptom to first call
| What you observe | Most likely stage | First call |
|---|---|---|
| One user, one missing email | Suppression or a bad address | GET /v1/email/suppression/check/{email} |
| Every notification since 09:00 missing | Domain or DNS regression | GET /v1/email/domain/get/{domain} |
State stuck at queued | Throttle or daily cap | GET /v1/email/domain/get/{domain} → reputation |
State sent, user says nothing arrived | Receiver-side filing | GET /v1/email/event/list for a bounced event |
Send call returned no message_id | Request rejected outright | Re-read the error body |
The last row deserves its own sentence: the send response carries accepted_recipients and suppressed_recipients separately, and a fully suppressed send is a success with an empty acceptance list, not an error.
Question one: what does the message say about itself
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/email/get/msg_DgOWYJSuArAxcSI9MCzYLSJp" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"message_id": "msg_DgOWYJSuArAxcSI9MCzYLSJp",
"state": "sent",
"channel": "email",
"to": "ops@example.com",
"vendor": "resend",
"created_at": 1784939403.456011
}
}
state moves queued → sent → delivered or bounced. A message parked at queued for more than a minute or two isn’t a DKIM problem — it’s a capacity or cap problem, and the domain call below is where you look. An unknown id comes back as EMAIL_NOT_FOUND, which usually means you’re querying the wrong account’s key rather than a lost message.
Question two: the timeline
GET /v1/email/get/{id} gives you one word. The event list gives you the sequence, which is what you need when the single word is misleading.
curl -sS "https://api.infrai.cc/v1/email/event/list?message_id=msg_DgOWYJSuArAxcSI9MCzYLSJp" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{ "type": "sent", "at": "2026-07-25T00:30:03.405585Z", "recipient": "ops@example.com", "message_id": "msg_DgOWYJSuArAxcSI9MCzYLSJp", "meta": { "vendor_message_id": "a0636ff4-56f6-4a52-8f49-23d60cb58cc7" } },
{ "type": "queued", "at": "2026-07-25T00:30:03.394546Z", "recipient": "ops@example.com", "message_id": "msg_DgOWYJSuArAxcSI9MCzYLSJp", "meta": { "vendor": "resend" } }
],
"count": 2,
"next_cursor": null
}
}
Newest first. Two events and no delivered means the upstream MTA took the message and hasn’t reported back yet — normal for the first few seconds, suspicious after five minutes. A bounced event carries the recipient it applies to, which matters on a multi-recipient notification where one address poisoned the batch.
Keep meta.vendor_message_id. It’s the handle you’ll need if the trail ever has to continue at the delivery vendor.
Question three: is the address blocked
curl -sS "https://api.infrai.cc/v1/email/suppression/check/unsub-probe@example.com" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
A suppressed: true response with reason: "unsubscribed" closes the investigation, and it’s the single most common cause of “one user isn’t getting notifications”. Hard bounces and complaints land on that list automatically. Removing an address is DELETE /v1/email/suppression/delete/{email} — do it only when you know why it was added, because re-mailing a complainer is how a domain reputation gets shredded.
Question four: the domain itself
If the failure is fleet-wide, stop looking at individual messages.
curl -sS "https://api.infrai.cc/v1/email/domain/list" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Each record carries status and a checks map covering spf_dns, dkim_dns, tracking_cname, dmarc_dns and mail_loopback. A DKIM regression shows up as dkim_dns flipping away from verified — usually because someone edited the zone file, or a key rotation replaced the TXT record and nobody republished it. PowerDMARC’s failure taxonomy is a good companion read when you need to work out why the signature broke rather than just that it did.
The triage script
One command, one verdict.
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 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 };
return { error: `${e.code}: ${e.message}` };
}
return payload.data;
}
const messageId = process.argv[2];
if (!messageId) throw new Error("usage: node triage.mjs <message_id>");
const msg = await get(`/v1/email/get/${messageId}`);
if (msg.error) {
console.error(`cannot read ${messageId} — ${msg.error}`);
process.exit(2);
}
const events = await get(`/v1/email/event/list?message_id=${messageId}`);
const rows = events.items ?? events.records ?? [];
const types = new Set(rows.map((r) => r.type));
const supp = await get(`/v1/email/suppression/check/${encodeURIComponent(msg.to)}`);
let verdict;
if (supp.suppressed) verdict = `BLOCKED: ${msg.to} is suppressed (${supp.reason})`;
else if (types.has("bounced")) verdict = "BOUNCED: receiver rejected it, check the address";
else if (msg.state === "queued") verdict = "THROTTLED: still queued, check the domain daily cap";
else if (types.has("delivered")) verdict = "DELIVERED: it is a filing problem, not a sending problem";
else verdict = `IN FLIGHT: state=${msg.state}, ${rows.length} events so far`;
console.log(verdict);
for (const r of rows) console.log(` ${r.at} ${r.type} ${r.recipient}`);
Point it at a message_id from your logs and it tells you which of the four stages failed, plus the raw timeline underneath. Wire the same three calls into your on-call tooling and the first ten minutes of every “email didn’t arrive” ticket disappear.
What this costs to run
Nothing. Message reads, event listing, suppression checks and domain reads are all free and rate-limited rather than metered — only the send is billable, at $0.000115 per recipient, verified 2026-07-25, with a $2 credit on a new account. Rates drift downward over time as upstream discounts land, so confirm before you build a budget on it:
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 limitation you should plan around
Infrai’s email namespace has no support for delivery webhooks. Eighteen routes, all pull — so a bounce reaches your system when you next poll, not the moment it happens. SendGrid’s event webhook and Amazon SES’s SNS notifications are push, and if you need a bounce to suspend an account within seconds you’d be better off with one of those, or with a short-interval poll driven by the platform’s own cron on the same key.
For a US or EU SaaS sending operational notifications, polling every 60 seconds is usually fine and considerably simpler than standing up a public webhook endpoint with signature verification. That’s the trade-off, stated plainly.
The thing that keeps the triage cheap is that all of it lives on one account. The notification job, the retry queue, the error tracker that caught the exception, the cron entry that runs the poll — same key, same bill, and the tenant attribution is a query rather than four dashboards you have to join by hand.