Email first, SMS on silence: normalising two delivery-status APIs

A Node 22 notification path that sends mail, polls the event feed, and falls back to text — with the field-name differences between the two status APIs written down.

A two-channel notification needs three things: a send that returns a handle, a poller that turns that handle into a verdict, and a rule that decides when silence counts as failure. Infrai gives you the first two on one key — POST /v1/email/send then GET /v1/email/event/list, POST /v1/sms/send then GET /v1/sms/status/{id} — but the two status surfaces return different field names, so most of the code you write is a translation layer.

The rest of this walks the email-primary shape, since that’s the cheaper channel and the one most events belong on.

Accepted is not delivered

POST /v1/email/send answering HTTP 200 means the gateway took custody of the message. It does not mean a mailbox took it. Send to a syntactically valid address at a domain with no MX records and you’ll get a clean 200 and a message_id; the failure surfaces minutes later in the event feed, not in your response object.

That gap is the entire reason a fallback design needs polling rather than a try/catch.

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/email/send" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
        "to": "dana@kb-sink-9f2a4c.dev",
        "subject": "Payment failed on invoice INV-2026-0412",
        "html": "<p>Your card was declined. Update it within 72 hours.</p>",
        "tags": ["billing.payment_failed"]
      }'
{
  "ok": true,
  "data": {
    "message_id": "msg_ddAG2jvMSTTtxCiGUHZAdQEk",
    "from_used": "no-reply@send.infrai.cc",
    "mode": "shared_sender",
    "accepted_recipients": ["dana@kb-sink-9f2a4c.dev"],
    "suppressed_recipients": []
  }
}

Omitting from is deliberate here — the shared sender works without any DNS setup, which is what you want while wiring the flow up. Check suppressed_recipients before you celebrate: an address on the account suppression list is accepted by the call and silently not delivered.

What each channel will actually tell you

EmailSMS
Latest stateGET /v1/email/get/{id}stateGET /v1/sms/status/{id}state
TimelineGET /v1/email/event/list?message_id=…GET /v1/sms/events/{id}
Timeline array keyitemsitems
Timestamp key on a timeline rowatoccurred_at
Failure explanationevent type (bounced, complained)failed_reason on the status object
Handle comes frommessage_id on the send responsemessage_id on the send response
Timeline usable on a standard accountyesno — answers 503 VENDOR_NOT_CONFIGURED
Read billingfreefree

The last row is the one that shapes the code. On the email side you get a genuine event history; on the SMS side, unless a vendor key is hydrated for the account, GET /v1/sms/events/{id} returns HTTP 503 and your only signal is the single state field from the status route. So the normaliser has to be built around the weaker of the two, not the richer one.

Reading the email timeline

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

curl -sS "https://api.infrai.cc/v1/email/event/list?message_id=msg_ddAG2jvMSTTtxCiGUHZAdQEk" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "items": [
      { "type": "sent", "at": "2026-07-26T05:32:12.278313Z", "recipient": "dana@kb-sink-9f2a4c.dev", "meta": { "vendor_message_id": "28507064-ee52-4a1d-af8e-a93bbbfa53de" } },
      { "type": "queued", "at": "2026-07-26T05:32:12.264848Z", "recipient": "dana@kb-sink-9f2a4c.dev", "meta": { "vendor": "resend" } }
    ],
    "next_cursor": null,
    "count": 2
  }
}

Newest first, and next_cursor is your pagination handle when a message has fanned out to several recipients. A bounced row is what triggers the fallback; an opened row is what tells you the fallback was unnecessary.

Reading the SMS side

curl -sS "https://api.infrai.cc/v1/sms/status/sms_notarealid" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": false,
  "error": {
    "code": "SMS_MESSAGE_NOT_FOUND",
    "http_status": 404,
    "message": "no sms message with id 'sms_notarealid' in this account's archive",
    "retryable": false
  }
}

That’s what a bad id looks like, and you’ll meet it first while wiring the worker. A real id returns state, vendor, attempt, last_event, delivered_at and failed_reason.

One probe, two adapters

// probe.mjs — normalise both status surfaces into one verdict.
// Run: INFRAI_API_KEY=your_infrai_api_key node probe.mjs
const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

async function api(method, path, payload) {
  const res = await fetch(`${BASE}${path}`, {
    method,
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: payload === undefined ? undefined : JSON.stringify(payload),
  });
  const json = await res.json();
  if (json.ok === false) {
    throw Object.assign(new Error(json.error.message), { code: json.error.code, status: res.status });
  }
  return json.data;
}

const HARD_EMAIL_FAILURES = new Set(["bounced", "complained", "dropped", "failed"]);

// Returns "delivered" | "failed" | "pending", never a channel-specific string.
export async function probe(channel, messageId) {
  if (channel === "email") {
    const feed = await api("GET", `/v1/email/event/list?message_id=${encodeURIComponent(messageId)}`);
    const types = (feed.items ?? []).map((e) => e.type);
    if (types.some((t) => HARD_EMAIL_FAILURES.has(t))) return "failed";
    if (types.includes("delivered") || types.includes("opened")) return "delivered";
    return "pending";
  }
  try {
    const s = await api("GET", `/v1/sms/status/${encodeURIComponent(messageId)}`);
    if (s.state === "delivered") return "delivered";
    if (s.state === "failed" || s.failed_reason) return "failed";
    return "pending";
  } catch (err) {
    if (err.code === "SMS_MESSAGE_NOT_FOUND") return "failed";
    throw err;
  }
}

console.log(await probe("email", "msg_ddAG2jvMSTTtxCiGUHZAdQEk"));

Three verdicts is the right vocabulary. Anything richer leaks channel details into the escalation rule, and the rule is the part you’ll want to change later.

The escalation

Silence is the interesting case. A bounce is easy — you know within seconds — but an email that sits at sent for four minutes on a payment-failure notice is functionally a failure even though nothing went wrong.

// escalate.mjs — email first, text on hard failure or on silence past the deadline.
// Run: INFRAI_API_KEY=your_infrai_api_key node escalate.mjs
import { probe } from "./probe.mjs";

const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const DEADLINE_MS = 240_000;
const POLL_EVERY_MS = 20_000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function send(path, body) {
  const res = await fetch(`${BASE}${path}`, {
    method: "POST",
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: JSON.stringify(body),
  });
  const json = await res.json();
  if (json.ok === false) throw Object.assign(new Error(json.error.message), { code: json.error.code });
  return json.data;
}

export async function notify(user, event) {
  const mail = await send("/v1/email/send", {
    to: user.email,
    subject: event.subject,
    html: event.html,
    tags: [event.type],
  });
  if ((mail.suppressed_recipients ?? []).length > 0) {
    return textInstead(user, event, "email_suppressed");
  }

  const started = Date.now();
  while (Date.now() - started < DEADLINE_MS) {
    await sleep(POLL_EVERY_MS);
    const verdict = await probe("email", mail.message_id);
    if (verdict === "delivered") return { channel: "email", id: mail.message_id };
    if (verdict === "failed") return textInstead(user, event, "email_failed");
  }
  return textInstead(user, event, "email_silent");
}

async function textInstead(user, event, reason) {
  if (!user.phone) return { channel: "none", reason };
  const sms = await send("/v1/sms/send", { to: user.phone, body: event.text });
  console.warn(`escalated to sms (${reason}) message_id=${sms.message_id} state=${sms.state}`);
  return { channel: "sms", id: sms.message_id, reason };
}

const demo = { email: "dana@kb-sink-9f2a4c.dev", phone: "+15551234567" };
if (process.env.SEND === "1") {
  console.log(await notify(demo, {
    type: "billing.payment_failed",
    subject: "Payment failed",
    html: "<p>Your card was declined.</p>",
    text: "Your card was declined. Update it within 72 hours.",
  }));
}

In practice you’d run this from a queue consumer rather than an in-process while loop, so a deploy doesn’t lose the pending escalations — but the state machine is the same either way.

The retry that quietly double-bills

email.send accepts an idempotency_key field, and in our testing two identical sends carrying the same key both went through and both were billed. The catch is that the field is accepted, so a reviewer glancing at the payload will assume dedupe is handled. It isn’t, at least not on the body-field variant.

So dedupe on your side: a unique index on (event_id, channel) in the outbox table, written before the send, is the version that actually holds. The same discipline covers the escalation itself — a worker restart that replays a job must not send the fallback text twice.

What the loop costs

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" | head -c 300

curl -sS "https://api.infrai.cc/v1/account/usage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Verified 2026-07-26: every status read here is free and rate-limited, so polling twelve times per notification adds nothing to the bill. Writes are the cost, and the ratio matters more than the rates — an SMS is billed per message at $0.007475 while email is published at $0.000115 per email, roughly sixty-five times cheaper, which is the real argument for making mail the primary channel and text the exception. New accounts get $2 of free credit. Pull today’s figures from GET /v1/discovery, reconcile them against GET /v1/account/usage (that’s the meter your invoice is built from), and expect the direction of travel to be downward.

Where this isn’t the right shape

Polling has a floor. If you need sub-second reaction to a bounce across millions of sends, a receipt stream beats a poll loop and you should stick with a provider that pushes them — Twilio’s SendGrid event webhook and Plivo’s message callbacks both do. Infrai doesn’t support outbound delivery webhooks on either channel today, GET /v1/sms/events/{id} needs a hydrated vendor key before it returns a timeline, and there are no X-RateLimit-* headers to read, so throttling is reactive.

For an event-notification path at ordinary volume, though, the trade is a good one. Both channels answer to the same key, the outbox behind them can live on the same account’s queue, and “what did payment-failure notifications cost this month” is one usage query with the tags you set at send time — not a reconciliation across two vendors with two invoices and two sets of webhook signatures to verify.

References

Browse more sms developer guides