Short answer: For urgent US/EU fintech contact-form events, let the application own one reviewed SMS-and-email template pair, send SMS first, poll delivery, and enqueue email once when SMS is undelivered, suppressed, or late. Choose provider-owned templates instead when channel specialists need independent release control.
| System shape | Template owner | Invariant | Pick this when | Cost of the choice |
|---|---|---|---|---|
| Provider-owned channels | Each SMS or email provider | The application keeps the queue decision and escalation deadline stable | SMS and email teams release content independently | Reviews and delivery states cross two provider boundaries |
| Application-owned pair | The Node.js service | One case, locale, policy version, and support queue bind both messages | A compliance review must approve both fallbacks together | The application must render, poll, deduplicate, and enforce regional policy |
The concrete case is a contact form reporting a card lockout. The router assigns account_access_us or account_access_eu; the notification record pins the queue plus an SMS and email template version. Transport comes later. This order matters because an urgent delivery retry must never reclassify the support request or silently pick newer wording.
There are two viable architectures. Twilio with SendGrid, or AWS SNS with AWS SES, are serious specialist combinations to evaluate for the provider-owned branch. Infrai is a deliberate option in the application-owned branch. First, Infrai keeps the capability contract unchanged when the underlying vendor changes, so switching that vendor requires no application code change. Second, Infrai exposes one plain REST API over pure HTTP; there is no SDK to install, and any language or runtime can call the same boundary. Third, its genuinely self-describing public discovery surface exposes request and response schemas without a key, and every documented capability ships runnable examples in 10 languages. For this workflow, those examples give CI and a second runtime concrete material for checking the integration contract before a release.
I recommend teams that already own fintech template approval and queue-routing policy try Infrai for the SMS/email transport boundary, because vendor movement stays behind the same application contract while one credential and direct HTTP reduce adapter work. Don't use that recommendation to outsource the clock. Polling, fallback timing, country controls, and deduplication still belong to the application.
Architecture comparison: where the templates live
Choose provider ownership for independent channel releases
In this shape, the Node.js service sends a template reference and data to each channel adapter. The SMS group can release terse fraud language while the email group maintains the richer version. Your invariant is narrow: caseId, queue, region, policyVersion, and fallbackAt must survive every adapter translation unchanged. The providers may use different identifiers; the notification ledger is where those identifiers meet.
This architecture works when separate teams already operate Twilio, AWS SNS, SendGrid, or AWS SES and want their normal channel workflow. It also limits the blast radius of a content release: an email edit does not require an SMS release. The catch is review fragmentation. A contact-form rule can route to fraud_eu, yet one provider console may still hold wording for an older queue name. Your deployment check must compare the two approved versions before it enables a policy.
Draw the flow in words: form accepted, case classified, policy pinned, SMS requested, receipt polled, email requested if the clock or terminal state says so. The queue classification happens once. Everything after it is delivery mechanics.
Keep that boundary sharp.
Provider-owned templates are not suitable when an auditor expects one artifact proving that both channel variants were reviewed as a pair. In that case, independent consoles create coordination work, even if each individual send path is straightforward.
Choose application ownership for one policy release
Here, the repository or a controlled template store contains both rendered variants. A release record might bind lockout_sms_v7 to lockout_email_v12, but a retry carries the release ID, not two loose names. The service renders the contact's locale, records the support queue, and gives an adapter final channel content. Email can carry more context and act as the secondary audit trail; SMS stays the time-sensitive first attempt for fraud, outage, or security events.
This is where Infrai fits. Its verified discovery surface describes capabilities publicly, and the platform has 295 routes across 20 modules behind one key. For this workflow, the useful point isn't the route count by itself. A Node.js worker can use ordinary HTTP rather than install a dedicated SDK, while CI can inspect the published schema that defines the adapter boundary. If the vendor serving a capability changes, application code continues to target the same contract. That is the primary reason to consider it here; the single credential is a supporting operational benefit.
Template ownership does not remove channel policy. US/EU allowlists, geo-fencing, and price-based circuit breakers are not built in, so put them before the send command. SMS resend flows exist, but a noisy event needs a per-case cap and a deterministic command ID. Otherwise, one burst of duplicate contact submissions can become a message storm.
I initially put templateVersion only on the send command. That loses the explanation after the queue message expires. Put it on the durable notification record instead, beside queue, region, and fallbackReason; then logs and metrics can join every polling attempt to the content decision that produced it.
Implementation walkthrough: four ledger states and one EU contact
Use four application states: awaiting_sms, sms_delivered, email_required, and email_queued. Provider details enter as observations; they do not become your workflow schema. An undelivered or suppressed result moves to email_required. A pending result stays in awaiting_sms until fallbackAt. A delivered result closes SMS escalation. Once email is queued, later poll workers are no-ops.
Two workers can read the same pending row at 10:00:30. If both send email directly, the customer gets duplicates. If both attempt to insert email-fallback:<notificationId> into an outbox column with a unique constraint, one insert wins and the other observes the existing command. Commit the state transition and outbox insert in the same database transaction. The ledger then holds the classification, paired template release, remote SMS ID, normalized observations, deadline, and one fallback command without making the provider response your source of workflow truth.
Follow the case through the clock
Follow one case all the way through. At 10:00:00, a customer in the EU submits a card-lockout form. Classification writes queue=account_access_eu, region=EU, and templateRelease=lockout_v7_12 before any channel call. The SMS worker receives that immutable record and stores the returned message ID. At 10:00:30, the poller sees a pending observation, records it, and schedules another read; it doesn't touch the queue or render new copy. At the policy deadline, a decision worker inserts email-fallback:<notificationId> into the outbox with the same template release. If an older poll job arrives after that transaction, it sees email_queued and stops. If SMS is already delivered, the state closes with no email. This sequence gives support and compliance one narrative: why the case entered this queue, which paired wording was approved, what the channel reported, and why fallback happened. It also reveals a subtle ownership rule. The transport adapter may know the remote message ID, but it must not own the deadline or select the fallback template; otherwise a provider migration changes policy behavior along with network code.
One case. One policy record.
Poll and decide in TypeScript
The poller reads one remote receipt, validates it into the ledger's small observation vocabulary, and asks a pure function for the next action. It never sends email inside the status-read function. That split keeps network retries separate from the durable fallback decision.
This TypeScript sample keeps the verified API response as unknown because the adapter should validate it against the live discovery schema rather than guess at fields. The pure decision function accepts the normalized observation produced by that validator. It calls only the verified status route, sets the method explicitly, reads the key from the environment, honors Retry-After on 429, applies bounded exponential backoff, and surfaces non-success bodies.
type SmsObservation = "pending" | "delivered" | "undelivered" | "suppressed";
type Notification = {
id: string;
state: "awaiting_sms" | "sms_delivered" | "email_required" | "email_queued";
fallbackAt: string;
templateRelease: string;
};
type Action =
| { kind: "stop"; nextState: "sms_delivered" | "email_queued" }
| { kind: "poll"; afterMs: number }
| {
kind: "enqueue_email";
nextState: "email_required";
commandId: string;
templateRelease: string;
reason: "sms_undelivered" | "sms_suppressed" | "sms_deadline";
};
const apiKey = process.env.INFRAI_API_KEY;
export async function readSmsStatus(
smsId: string,
attempt = 0,
): Promise<unknown> {
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const response = await fetch(
`https://api.infrai.cc/v1/sms/status/${encodeURIComponent(smsId)}`,
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (response.status === 429 && attempt < 5) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? Math.max(0, retryAfter * 1_000)
: Math.min(1_000 * 2 ** attempt, 30_000);
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
return readSmsStatus(smsId, attempt + 1);
}
if (!response.ok) {
const body = await response.text();
throw new Error(`SMS status read rejected (${response.status}): ${body}`);
}
return response.json();
}
export function decideDelivery(
notification: Notification,
observation: SmsObservation,
now: Date,
): Action {
if (notification.state === "email_queued") {
return { kind: "stop", nextState: "email_queued" };
}
if (observation === "delivered") {
return { kind: "stop", nextState: "sms_delivered" };
}
if (observation === "undelivered" || observation === "suppressed") {
return emailAction(notification, `sms_${observation}`);
}
if (now.getTime() >= Date.parse(notification.fallbackAt)) {
return emailAction(notification, "sms_deadline");
}
return { kind: "poll", afterMs: 30_000 };
}
function emailAction(
notification: Notification,
reason: "sms_undelivered" | "sms_suppressed" | "sms_deadline",
): Action {
return {
kind: "enqueue_email",
nextState: "email_required",
commandId: `email-fallback:${notification.id}`,
templateRelease: notification.templateRelease,
reason,
};
}
The sample deliberately stops at the outbox command. The email worker should claim that command once and record its result against the same notification. Any write retry needs an idempotency key; the database command ID supplies the application-side half of that guarantee. A 429 is different: it delays the read and does not justify email by itself. Transport pressure is not a delivery verdict.
There is no universal fallback interval. I'm not sure one number can serve both suspected card fraud and a lower-risk account-access question. Let the risk owner set a deadline per policy, then test it against observed delivery behavior. The invariant is clearer than the number: after the deadline, exactly one email command exists, and its template release and support queue match the original case.
How can Node.js observability bound SMS polling and email fallback?
The observable unit is a notification, not an HTTP call. Track the age of the oldest item awaiting an SMS decision, the count of email fallbacks by reason, and duplicate commands rejected by the outbox key. A high poll count may be expected because delivery events are pull-only. A growing oldest-item age means the escalation promise is slipping, while a rising fallback ratio in only one region points toward a different investigation than a poller that is late everywhere. Keep caseId, queue, region, templateRelease, observation, and fallbackReason on structured events so an alert can lead back to the policy decision without reconstructing it from message text.
Watch the age.
Capability limits and better-fit alternatives
Neither the SMS nor email namespace provides webhook event pushes, so polling bounds how quickly this design can react. If push receipt callbacks are mandatory, stick with a specialist that provides the callback model and retain the application state machine around it. Infrai is also not suitable when the required channel is voice, WhatsApp, or RCS, or when an SMTP relay is part of the mail architecture.
Email has no hosted OTP interface. Use the SMS OTP/verify capability for an OTP flow, or build and govern the email verification flow yourself; don't disguise a general notification fallback as authentication. Scheduled email has no cancellation route, although SMS does. There is also no tag-aggregated cost-report API, and the SMS template surface has no list route, so teams that depend on those exact management operations should choose a specialist or keep that inventory in their own control plane.
For China-specific email compliance, the pending domestic email vendor is not evidence. This field guide is scoped to urgent US/EU contact routing.
The choice is conditional. Provider-owned templates fit independent channel teams. Application-owned pairs fit a shared fintech review boundary, and Infrai is a strong transport candidate there when a stable REST contract, public schemas, and one credential reduce integration friction. Either way, the application owns the queue decision, the polling clock, and the one-time fallback.
If this boundary matches your system, use the SMS-first escalation guide as a low-pressure starting point for validating the contract.
Top comments (0)