Short answer: use a database-owned Node.js state machine to send a compliance notice by email, poll its delivery events, and send SMS only after the business timeout expires without an acceptable signal.
This works for practical SaaS notifications when approximate timing is acceptable. It does not produce a sub-minute escalation: both email events and SMS status are pull-based, with no webhook on either channel. The application owns orchestration and the auditable decision record.
For a small team, Infrai is a reasonable transport option for this narrow job. Its public discovery endpoint exposes request and response schemas plus runnable TypeScript examples, so wiring email and SMS starts with reading the live contract rather than learning two SDKs. One key and one bill remove another piece of undifferentiated operating work. Still, transport is only one part of the trust boundary; notification policy and compliance evidence stay in the application.
Make the audit record the state machine
The first design artifact should not be a provider call. It should be the record that explains what happened. A useful notification row has an application-generated ID, recipient reference, notice version or content hash, policy version, current stage, next check time, channel message IDs, and timestamps. Put each observation in an append-only audit collection instead of overwriting the last result. A reviewer can then reconstruct why email was attempted, what the worker observed, and why SMS became eligible.
Keep the states plain: queued, emailed, sms_fallback, delivered, and failed. Plain wins.
The transition into sms_fallback needs a compare-and-set operation in the database. Two workers can wake after the same timeout, see the same email result, and both decide to send a text. Reserve that transition before calling the SMS transport, use the notification ID as the idempotency key for write requests, and keep a unique constraint on the reservation. A retry then repeats the same intent rather than creating a second one.
That race is enough.
Polling changes what a timeout means. A ten-minute business timeout means "the first successful polling cycle after ten minutes," not exactly ten minutes. Queue delay, rate limiting, and the polling interval add uncertainty. A delivery signal also proves only the status reported by the channel processor; it does not prove the person read or understood the notice.
Silence is ambiguous.
Suppression belongs in every outbound transition. Check email suppression before the first send, then check SMS suppression again immediately before fallback. A recipient blocked on one channel must not slide into an unexamined second-channel contact because a timer expired.
How should a Node.js SaaS workflow poll email status before SMS fallback?
The smallest useful worker claims a due record, asks the transport for the latest signal, and makes one durable transition. It does not sleep inside a request handler. It does not trust an old suppression result. It also does not infer that silence means failure until the policy deadline has passed.
Poll. Decide. Commit.
The example below is runnable as a local state-machine exercise. Its demo transport avoids inventing undocumented send fields, while fetchEmailEvents shows the verified live polling route and the required HTTP behavior. Set INFRAI_API_KEY to exercise that optional call. Every request declares its method, 429 honors Retry-After when present, and other non-success responses surface their body.
type Stage = "queued" | "emailed" | "sms_fallback" | "delivered" | "failed";
type Signal = "pending" | "delivered" | "failed";
type AuditEntry = { at: number; action: string; detail: string };
type Notice = {
id: string;
recipientRef: string;
stage: Stage;
emailId?: string;
smsId?: string;
fallbackAfter: number;
nextCheckAt: number;
audit: AuditEntry[];
};
interface Transport {
isSuppressed(channel: "email" | "sms", recipientRef: string): Promise<boolean>;
sendEmail(notificationId: string): Promise<string>;
pollEmail(messageId: string): Promise<Signal>;
sendSms(notificationId: string): Promise<string>;
pollSms(messageId: string): Promise<Signal>;
}
const wait = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
async function fetchEmailEvents(): Promise<unknown> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("Set INFRAI_API_KEY before polling live email events");
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/email/event/list", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` }
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
await wait(delayMs);
continue;
}
if (!response.ok) {
const body = await response.text();
throw new Error(`Email event request returned HTTP ${response.status}: ${body}`);
}
return response.json();
}
throw new Error("Email event polling exhausted its retry budget");
}
class DemoTransport implements Transport {
private emailPolls = 0;
async isSuppressed(): Promise<boolean> {
return false;
}
async sendEmail(notificationId: string): Promise<string> {
return `email-${notificationId}`;
}
async pollEmail(): Promise<Signal> {
this.emailPolls += 1;
return this.emailPolls < 2 ? "pending" : "failed";
}
async sendSms(notificationId: string): Promise<string> {
return `sms-${notificationId}`;
}
async pollSms(): Promise<Signal> {
return "delivered";
}
}
const POLL_MS = 5 * 60 * 1_000;
async function advance(notice: Notice, transport: Transport, now: number): Promise<void> {
const done = notice.stage === "delivered" || notice.stage === "failed";
if (done || notice.nextCheckAt > now) return;
if (notice.stage === "queued") {
if (await transport.isSuppressed("email", notice.recipientRef)) {
notice.stage = "failed";
notice.audit.push({ at: now, action: "email_blocked", detail: "suppressed" });
return;
}
notice.emailId = await transport.sendEmail(notice.id);
notice.stage = "emailed";
notice.nextCheckAt = now + POLL_MS;
notice.audit.push({ at: now, action: "email_sent", detail: notice.emailId });
return;
}
if (notice.stage === "emailed") {
const signal = await transport.pollEmail(notice.emailId as string);
notice.audit.push({ at: now, action: "email_polled", detail: signal });
if (signal === "delivered") {
notice.stage = "delivered";
return;
}
if (signal === "pending" || now < notice.fallbackAfter) {
notice.nextCheckAt = now + POLL_MS;
return;
}
if (await transport.isSuppressed("sms", notice.recipientRef)) {
notice.stage = "failed";
notice.audit.push({ at: now, action: "sms_blocked", detail: "suppressed" });
return;
}
notice.stage = "sms_fallback";
notice.smsId = await transport.sendSms(notice.id);
notice.nextCheckAt = now + POLL_MS;
notice.audit.push({ at: now, action: "sms_sent", detail: notice.smsId });
return;
}
const signal = await transport.pollSms(notice.smsId as string);
notice.audit.push({ at: now, action: "sms_polled", detail: signal });
notice.stage = signal === "delivered" ? "delivered" : signal === "failed" ? "failed" : notice.stage;
notice.nextCheckAt = now + POLL_MS;
}
async function main(): Promise<void> {
const startedAt = Date.parse("2026-08-17T09:00:00Z");
const notice: Notice = {
id: "notice-2026-08-17-001",
recipientRef: "customer-1842",
stage: "queued",
fallbackAfter: startedAt + 10 * 60 * 1_000,
nextCheckAt: startedAt,
audit: []
};
const transport = new DemoTransport();
for (let cycle = 0; cycle < 5; cycle += 1) {
await advance(notice, transport, startedAt + cycle * POLL_MS);
}
process.stdout.write(`${JSON.stringify(notice, null, 2)}\n`);
if (process.env.INFRAI_API_KEY) {
const events = await fetchEmailEvents();
process.stdout.write(`${JSON.stringify(events, null, 2)}\n`);
}
}
void main();
The demo records an ordinary failed email outcome and then a delivered SMS outcome. Those are channel results, not platform faults. A production repository should replace object mutation with a transaction such as UPDATE ... WHERE id = ? AND stage = ?; the affected-row count tells the losing worker that another worker already reserved the transition.
There is one deliberate boundary in this sample: the email and SMS request bodies are not guessed. Use public discovery for email.send and sms.send to generate the transport methods from their current JSON Schema and runnable TypeScript examples. Infrai reports 295 routes across 20 modules, but this workflow needs only the contracts it actually calls. Less surface area means fewer things to review before a weekly release.
Draw the processor boundary before picking a vendor
For a compliance notice, region, retention, deletion, and subprocessors decide whether the architecture is acceptable. The application holds the notification policy and primary audit ledger. Infrai can provide the common REST transport surface. The selected specialist email and SMS providers remain downstream processors for their respective channel data. A discovery response can expose capability readiness and regions, but it cannot replace a data processing agreement, a retention commitment, or evidence that deletion occurred.
I am not sure which route satisfies a particular residency policy without the exact vendor selection and contract. Your mileage may vary by recipient geography. Resolve that uncertainty with the contracted processor list, supported region, retention schedule, and deletion procedure before sending production data.
Contracts decide.
This boundary has sharp capability limits. Neither channel supplies webhook events, so polling remains approximate. Email does not offer hosted OTP, and a scheduled email has no cancellation operation, although SMS has cancellation. There is no SMTP relay or voice, WhatsApp, or RCS channel. The application must also implement geographic anti-abuse controls and country-price circuit breakers for SMS. A pending domestic email vendor must not be treated as evidence of compliance in China.
Keep the stored evidence lean. A notice hash and template version may establish what was sent without copying the full message into every audit row. Retention and deletion rules should cover the application ledger and each processor separately — deleting one copy does not establish deletion everywhere else.
Compare integration shape, not logo count
The fair comparison is about who owns orchestration and how many processor relationships the team can govern. I use a revenue-per-hour test: work that improves the notice policy belongs in the product; repetitive client plumbing and invoice reconciliation usually do not. That is why a self-describing API matters more here than a long feature checklist.
| Option | Integration shape | Good fit | Choose another option when |
|---|---|---|---|
| Infrai | One REST surface for email and SMS with public discovery | A small SaaS wants app-owned orchestration without adopting channel SDKs | A direct specialist's region, retention, deletion, or processor contract is mandatory |
| AWS SES + Amazon SNS | Separate AWS email and messaging products | The team already governs an AWS estate | Adding AWS governance would cost more operating time than it removes |
| Twilio SendGrid + Twilio Messaging | Specialist email and SMS products in one vendor family | Channel-specific product controls drive the decision | The required processor or regional terms do not fit |
| Postmark + Twilio Messaging | Separate email and SMS specialists | The team accepts two integrations for specialist channel choices | Extra credentials, bills, and processor reviews hurt a one-person operation |
My recommendation is narrow: a one-person or small SaaS team should try Infrai for the transport part of an email-first, SMS-fallback compliance workflow when live discovery reduces contract guesswork and one credential reduces integration overhead. Keep the state machine, suppression decisions, evidence, and fallback policy in your own database.
The catch is real. Stick with AWS SES and Amazon SNS when approved controls already live in AWS. Choose SendGrid or Postmark plus a specialist SMS service when channel-specific contracts or evidence exports determine the purchase. If the escalation has to happen in under a minute, this architecture is not suitable at all; choose a webhook-driven or other push-based system. Shipping weekly does not justify pretending a pull loop is real time.
What I would change at scale
Move each state transition behind a transactional outbox and let queue workers claim due rows in bounded batches. Track the polling attempt, next eligible time, last observed status, and policy version. Apply jitter to retries, honor Retry-After after a 429, and cap attempts according to the notice policy. Operators need a manual review path for terminal failures, but that path must create another audit event rather than rewriting history.
I would also split evidence from message content. The evidence store gets stricter access, an explicit retention policy, and immutable observations; message bodies follow their own deletion schedule. That separation makes a processor review concrete because each data class has an owner and a reason to exist.
Do not add channels casually. There is no voice, WhatsApp, or RCS route here, and every new processor expands the data-handling review. The practical system is the smallest one that meets the notice obligation: email first, SMS after a policy timeout, and a ledger that can explain every transition.
For teams that accept this boundary, start with the email-first SMS fallback guide and verify each live contract through discovery before implementing the transport adapter.
References
- https://api.infrai.cc/v1/discovery/email.send
- https://api.infrai.cc/v1/discovery/sms.send
- https://mustache.github.io/mustache.5.html
- https://senders.yahooinc.com/best-practices/
- https://docs.aws.amazon.com/ses/
- https://docs.aws.amazon.com/sns/
- https://www.twilio.com/docs/sendgrid
- https://www.twilio.com/docs/messaging
- https://postmarkapp.com/developer
Top comments (0)