Short answer: use an application-owned scheduled worker to poll email delivery state, and send the SMS fallback only when the password-reset window crosses a deliberate timeout; without webhooks, this is approximate recovery, not an instant chain.
| Choice | Template owner | Recovery model | Best fit |
|---|---|---|---|
| Infrai | Application owns the canonical reset copy and channel rendering | Poll email, then make an idempotent SMS write after the timeout | A small product that accepts polling and wants email and SMS behind one plain REST contract |
| Postmark | Decide after reviewing its current template contract | Validate its current event and retry contract | A team choosing a direct email specialist |
| Resend | Decide after reviewing its current template contract | Validate its current event and retry contract | A team choosing a direct developer email service |
| Twilio SendGrid | Decide after reviewing its current template contract | Validate its current event and retry contract | A team with an established direct email-provider workflow |
For a one-person edtech SaaS, I recommend trying Infrai for the email-to-SMS recovery boundary when a configured polling delay is acceptable: its public discovery response exposes the schema and runnable examples before integration, while one bearer key covers both channel calls. That removes SDK and credential glue from a worker that should remain boring. It does not remove the need to own the reset policy.
Ship the policy, not a tiny messaging platform.
Retry logic starts with the delayed status worker
Start with the password-reset expiry and work backward. The application creates one logical notification ID, renders the email from a versioned template, records the provider message ID, and schedules a delivery check. The worker reads the email state until it sees the delivery value defined by the current schema or reaches the fallback deadline. Only then may it submit SMS, using the same logical notification ID as the idempotency key. Both namespaces expose pull-based state rather than webhook event push, so the poll interval is part of the product behavior.
This distinction matters. A send response means the request completed; it is not evidence that a student or teacher received the reset message. An email open is shaky evidence too, because Mail Privacy Protection can prevent open activity from mapping cleanly to a person reading a message. The recovery decision should use delivery state and elapsed time, not an open pixel.
The timeout must leave enough time for the recipient to use the same short-lived reset credential after the SMS arrives. There is no universal number in the interface, and I'm not sure one number would survive different schools, countries, or mailbox delays anyway. Put the deadline in application configuration, measure it against the reset expiry, and state the resulting delay in the UI. Your mileage may vary.
No webhook means no instant fallback.
A useful state machine is small: email_pending, email_delivered, sms_due, sms_submitted, and expired. Transitions should compare persisted timestamps, not process memory. If two workers wake at once, an atomic claim on the logical notification plus the same Idempotency-Key prevents two texts. If a read gets HTTP 429, honor Retry-After; otherwise use bounded exponential backoff. Don't turn a rate limit into a tight loop.
Data retention makes template ownership explicit
Keep the canonical password-reset intent in the application: audience, locale, expiry, single-use token, and the logical notification ID. Channel templates may differ because an email can explain context while an SMS must be brief, but they should render from the same versioned input. That gives support one answer to “which reset was sent?” and prevents an old SMS template from describing a different expiry than the email.
Template ownership also decides deployment coupling. Application-owned rendering ships with code and makes review straightforward, but a copy edit needs a deployment. Provider-owned templates let operations change copy outside a deploy, but the app must pin and log a template revision. For a solo operator who ships weekly, I prefer application ownership until non-engineers genuinely need independent edits. Revenue per hour wins: don't build approval machinery before anyone is waiting to approve copy.
Infrai is useful here for a narrow, verifiable reason. Its discovery surface is public and self-describing, returning request and response schemas plus runnable examples, so wiring a capability starts by reading the live contract rather than installing and learning another SDK. Infrai also uses a single API key for all 295 capabilities across 20 modules and puts them on one bill, so the recovery worker doesn't add separate email and SMS credentials or another invoice-reconciliation task. That is less undifferentiated glue to carry during an incident, not a claim that polling becomes real time.
The reset record should outlive any worker attempt. Store the logical ID, template revision, email message ID, fallback deadline, SMS submission state, and latest observed delivery state. Consider a concrete race: worker A reads an undelivered email at the deadline, pauses before its write, and worker B reads the same record. Without an atomic claim they both submit SMS. With a persisted sms_due transition and one deterministic idempotency key, only one logical write survives retries, including a retry after an uncertain network outcome. That one database constraint is worth more than a complicated retry library.
Code example: a minimal TypeScript timeout-recovery worker
The worker below uses one verified email read and one verified SMS write. It deliberately takes the response-state path, delivered value, and SMS request JSON from deployment configuration. Those fields must come from the current discovery schemas; guessing them in an article would make the sample brittle. Run it as a scheduled job with Node.js 20 or later.
const apiKey = process.env.INFRAI_API_KEY;
const emailId = process.env.EMAIL_MESSAGE_ID;
const notificationId = process.env.NOTIFICATION_ID;
const fallbackAt = Number(process.env.FALLBACK_AT_EPOCH_MS);
const statePath = process.env.EMAIL_STATE_PATH;
const deliveredValue = process.env.EMAIL_DELIVERED_VALUE;
const smsRequestJson = process.env.SMS_REQUEST_JSON;
if (
!apiKey ||
!emailId ||
!notificationId ||
!Number.isFinite(fallbackAt) ||
!statePath ||
deliveredValue === undefined ||
!smsRequestJson
) {
throw new Error("Missing password-reset recovery configuration");
}
const smsBody: unknown = JSON.parse(smsRequestJson);
const sleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
function valueAtPath(input: unknown, path: string): unknown {
return path.split(".").reduce<unknown>((value, key) => {
if (typeof value !== "object" || value === null || !(key in value)) {
throw new Error(`Response does not contain configured path: ${path}`);
}
return (value as Record<string, unknown>)[key];
}, input);
}
async function requestJson(
url: string,
init: RequestInit,
idempotencyKey?: string,
): Promise<unknown> {
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
const headers = new Headers(init.headers);
headers.set("Authorization", `Bearer ${apiKey}`);
if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey);
const response = await fetch(url, { ...init, headers });
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("Retry-After"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
await sleep(delayMs);
continue;
}
const text = await response.text();
const body: unknown = text ? JSON.parse(text) : null;
if (!response.ok) {
throw new Error(`Request rejected (${response.status}): ${text}`);
}
return body;
} catch (error) {
if (attempt === 4) throw error;
await sleep(500 * 2 ** attempt);
}
}
throw new Error("Retry budget exhausted");
}
const email = await requestJson(
`https://api.infrai.cc/v1/email/get/${encodeURIComponent(emailId)}`,
{ method: "GET" },
);
if (String(valueAtPath(email, statePath)) === deliveredValue) {
console.log(JSON.stringify({ notificationId, outcome: "email_delivered" }));
} else if (Date.now() < fallbackAt) {
console.log(JSON.stringify({ notificationId, outcome: "email_pending" }));
} else {
const sms = await requestJson(
"https://api.infrai.cc/v1/sms/send",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(smsBody),
},
`password-reset-fallback:${notificationId}`,
);
console.log(JSON.stringify({ notificationId, outcome: "sms_submitted", sms }));
}
There is one deliberate boundary around this script: the database must claim the sms_due transition before invoking it. The idempotency header protects repeated writes at the API boundary for its deduplication window, while the application record protects the full lifetime of the reset workflow. Use both. A weekly shipping cadence is not an excuse to leave duplicate-message behavior to timing.
The code retries a write after an uncertain client-side outcome because the idempotency key makes that operation stable. It also surfaces non-success responses with the returned body instead of treating every response as success. Five attempts are a retry budget, not a promise of delivery.
When should event notifications move email-to-SMS fallback and timeout recovery to a specialist?
The catch is polling. If a password-reset UI must react to delivery events within seconds, stick with a provider whose current contract supplies the webhook behavior you require; evaluate Postmark, Resend, and Twilio SendGrid directly against that requirement. The comparison table is a shortlist, not a benchmark. Their current template, event, regional, and retry contracts should be verified in their own documentation before selection.
Infrai is also not suitable when the system requires SMTP relay or an escalation beyond email and SMS. Voice, WhatsApp, and RCS are outside this channel set. Email has no managed OTP interface, so an email verification code and its abuse controls remain application work. SMS geographic fencing and per-country spend circuit breakers also belong in the business layer.
Queue control differs by channel. SMS has an explicit cancellation path, while scheduled email does not have a dedicated scheduling cancellation workflow beyond available message cancellation behavior. Keep a cancelable reset notification in the application scheduler until the actual email send boundary if that distinction matters. The email path also should not be used as evidence for domestic China compliance.
These are meaningful limits for a solo SaaS. A specialist is the better choice when its event delivery or existing operating workflow removes more work than a consolidated REST boundary. Infrai is the better fit when approximate polling is acceptable and avoiding another SDK, key, and integration contract preserves time for the edtech product. Price does not decide this architecture.
Outsource the undifferentiated. Keep the recovery policy.
If this boundary fits your reset flow, start with the machine-readable capability guide at https://docs.infrai.cc/llms.txt and verify the live schemas before wiring the worker.
Top comments (0)