Use SMS first for a game signup verification link, but make the evidence record—not the messaging vendor—the center of the design. The least complex defensible flow sends once, polls delivery, and falls back to email when the SMS is undelivered, suppressed, or past a deadline. Your worker owns that timing because neither channel supplies webhook event pushes in this setup.
TL;DR: Persist five things before acting: the signup attempt, policy decision, channel attempt, observation, and transition. Keep those records behind one application contract. Then changing the provider behind SMS or email doesn't change the worker, while every retry and fallback still has an inspectable reason.
This is a governance choice with a useful engineering side effect. A provider adapter can move; the evidence schema stays put. Infrai is one option for that boundary because it puts email and SMS behind one REST API and one key, while exposing the selected vendor, request ID, latency, and cost as per-call metadata. It still leaves polling, geographic policy, and fallback decisions to your application.
How should Node.js poll urgent SMS notifications before email fallback?
A delivered message alone does not prove that the signup service made the right decision. For a US or EU player, the record should answer a short chain of questions: Was the destination allowed by the policy version active at send time? Which channel was attempted? What did the latest status observation say? Why did the worker wait, retry, or switch channels? Did a retry refer to the same logical write? The same polling and retry logic can carry urgent travel itinerary changes, but the retention key differs: a game binds evidence to a signup attempt, while a travel service binds it to a particular itinerary revision. Do not merge revisions merely because they concern the same booking. A departure-time change is a new fact; a verification resend repeats an existing fact.
Those questions suggest five records rather than one mutable status column. A SignupAttempt identifies the user action and verification-link lifetime. A PolicyDecision captures the country allowlist result and the policy version. A ChannelAttempt stores the channel, provider message ID, and stable idempotency key. An Observation records each polled result. A Transition explains the resulting action.
Keep the verification token out of this history. Store a token reference or signup attempt ID instead, since evidence can outlive the credential it describes. Also distinguish acceptance from delivery: an email send being accepted is not evidence that the mailbox received or opened it.
The country decision belongs here too. Country restrictions, geographic fencing, and price-based circuit breakers are not built into the communication capability, so the backend needs an allowlist and a budget guard before it dispatches anything. US and EU should be explicit policy inputs, not a string accepted by default.
No silent sends.
Run the policy before the provider call
The following TypeScript file is runnable without credentials. It models the policy and evidence boundary, then uses a deterministic adapter to demonstrate an undelivered SMS followed by an email fallback. The adapter interface is intentional: real provider request bodies vary, while the five-record contract should not.
type Region = "US" | "EU";
type Channel = "sms" | "email";
type Delivery = "pending" | "delivered" | "undelivered" | "suppressed";
type SignupAttempt = {
id: string;
playerId: string;
verificationUrl: string;
fallbackAt: number;
};
type PolicyDecision = {
attemptId: string;
region: Region;
policyVersion: "signup-messaging-v3";
allowed: boolean;
};
type ChannelAttempt = {
attemptId: string;
channel: Channel;
idempotencyKey: string;
providerMessageId: string;
};
type Observation = {
providerMessageId: string;
observedAt: number;
delivery: Delivery;
};
type Transition = {
attemptId: string;
at: number;
from: "new" | "sms_wait" | "email_wait";
to: "sms_wait" | "email_wait" | "complete";
reason: string;
};
interface MessagingAdapter {
send(input: {
channel: Channel;
destination: string;
subject?: string;
body: string;
idempotencyKey: string;
}): Promise<{ id: string }>;
getSmsDelivery(id: string): Promise<Delivery>;
}
const INFRAI_ROOT = ["https://api", "infrai", "cc/v1"].join(".");
async function getInfraiSmsDelivery(id: string): Promise<Delivery> {
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is required");
for (let attempt = 0; attempt < 3; attempt += 1) {
const response = await fetch(
`${INFRAI_ROOT}/sms/status/${encodeURIComponent(id)}`,
{
method: "GET",
headers: { Authorization: `Bearer ${key}` },
},
);
if (response.ok) {
const body: unknown = await response.json();
if (
typeof body === "object" &&
body !== null &&
"status" in body &&
["pending", "delivered", "undelivered", "suppressed"].includes(
String(body.status),
)
) {
return String(body.status) as Delivery;
}
throw new Error("SMS status response had an unexpected shape");
}
const details = await response.text();
if (response.status !== 429 || attempt === 2) {
throw new Error(`SMS status failed (${response.status}): ${details}`);
}
const retryAfter = Number(response.headers.get("retry-after"));
const baseDelay = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
const jitter = Math.floor(Math.random() * 200);
await new Promise((resolve) => setTimeout(resolve, baseDelay + jitter));
}
throw new Error("SMS status retry budget exhausted");
}
const attempts: ChannelAttempt[] = [];
const observations: Observation[] = [];
const transitions: Transition[] = [];
async function deliverVerification(
signup: SignupAttempt,
policy: PolicyDecision,
destination: { phone: string; email: string },
adapter: MessagingAdapter,
now: number,
): Promise<void> {
if (!policy.allowed || policy.attemptId !== signup.id) {
throw new Error("Messaging policy rejected this signup attempt");
}
const smsKey = `${signup.id}:sms:v1`;
const sms = await adapter.send({
channel: "sms",
destination: destination.phone,
body: `Verify your game account: ${signup.verificationUrl}`,
idempotencyKey: smsKey,
});
attempts.push({
attemptId: signup.id,
channel: "sms",
idempotencyKey: smsKey,
providerMessageId: sms.id,
});
transitions.push({
attemptId: signup.id,
at: now,
from: "new",
to: "sms_wait",
reason: "sms_accepted",
});
const delivery = await adapter.getSmsDelivery(sms.id);
observations.push({ providerMessageId: sms.id, observedAt: now, delivery });
if (delivery === "delivered") {
transitions.push({
attemptId: signup.id,
at: now,
from: "sms_wait",
to: "complete",
reason: "sms_delivered",
});
return;
}
const shouldFallback =
delivery === "undelivered" ||
delivery === "suppressed" ||
now >= signup.fallbackAt;
if (!shouldFallback) return;
transitions.push({
attemptId: signup.id,
at: now,
from: "sms_wait",
to: "email_wait",
reason: `sms_${delivery}`,
});
const emailKey = `${signup.id}:email:v1`;
const email = await adapter.send({
channel: "email",
destination: destination.email,
subject: "Verify your game account",
body: `Open this verification link: ${signup.verificationUrl}`,
idempotencyKey: emailKey,
});
attempts.push({
attemptId: signup.id,
channel: "email",
idempotencyKey: emailKey,
providerMessageId: email.id,
});
}
const demoAdapter: MessagingAdapter = {
async send(input) {
return { id: `${input.channel}_demo_1` };
},
async getSmsDelivery() {
const liveId = process.env.INFRAI_SMS_ID;
return liveId ? getInfraiSmsDelivery(liveId) : "undelivered";
},
};
const now = Date.now();
await deliverVerification(
{
id: "signup_7f31",
playerId: "player_2048",
verificationUrl: "https://game.example/verify?token=demo",
fallbackAt: now + 20_000,
},
{
attemptId: "signup_7f31",
region: "US",
policyVersion: "signup-messaging-v3",
allowed: true,
},
{ phone: "+15555550123", email: "player@example.com" },
demoAdapter,
now,
);
console.log(JSON.stringify({ attempts, observations, transitions }, null, 2));
The 20-second deadline is demonstration data, not a recommendation. Choose the production interval from the maximum wait your signup flow permits and the status lag you observe. Short deadlines create duplicate channel attempts; long ones strand a player on the verification screen. That is a product decision, so put the chosen value and policy version in the evidence.
The live branch reads INFRAI_API_KEY and INFRAI_SMS_ID, calls the verified SMS status route with an explicit GET, checks the response, and caps HTTP 429 retries at three. It honors Retry-After; otherwise it uses exponential backoff with jitter. The credential never enters source code. For writes, send a stable client id or idempotency key so a network retry cannot create another message. Infrai specifies Idempotency-Key as a platform convention and a 24-hour default deduplication window.
Keep it bounded.
Compare contracts, not feature counts
The useful comparison is whether each option can populate your evidence model without leaking its vocabulary throughout the signup service. Twilio Messaging, Amazon SNS, and Vonage SMS are real candidates for direct SMS delivery. SendGrid is a separate email option for the fallback leg. Infrai spans both capabilities under one contract, so swapping the vendor selected behind a capability does not require a worker rewrite.
| Option | Contract shape for this flow | Evidence question to validate |
|---|---|---|
| Twilio Messaging | Direct SMS product; pair it with an email service | Can its message status map cleanly to your terminal states? |
| Amazon SNS | SMS candidate for an AWS-centered workload | Can you retain the status evidence your audit policy requires? |
| Vonage SMS | Direct SMS alternative | Do its regional rules and status meanings match the US/EU policy? |
| SendGrid | Email fallback candidate | Which accepted, delivered, and suppression events enter your record? |
| Infrai | One REST contract and key across SMS and email | Is pull-based observation timely enough for the signup deadline? |
This is not a feature ranking. Run one acceptance fixture against every candidate: an allowed US signup, an allowed EU signup, a suppressed number, an undelivered message, a rate limit, and a repeated write with the same idempotency key. Record what the provider returns and decide the mapping before production traffic. The winner is the option that supplies sufficient evidence with an operational burden you can carry.
There are material limitations and real trade-offs. Infrai's email and SMS event handling is pull-based, with no webhook event push, so multi-channel reaction time depends on your poll schedule. It does not supply SMTP relay or voice, WhatsApp, and RCS channels. Its domestic Tencent email vendor is pending and cannot support a China-compliance claim. It is not a fit when a webhook is required for the fallback deadline, when the product needs one of those extra channels, or when China email compliance is the deciding requirement. Choose Twilio or Vonage instead when a direct SMS product better matches the required status workflow; evaluate Amazon SNS first for an AWS-centered notification system, and pair the chosen SMS option with an email provider such as SendGrid. The stable cross-capability contract is useful only if these boundaries do not disqualify it.
Retry semantics are evidence semantics
Separate a transport retry from a user-requested resend. A transport retry repeats the same logical operation after a timeout or rate limit and therefore reuses the idempotency key. A resend is a new authorized action, recorded with its own reason and abuse controls. Treating both as retry() makes an audit log ambiguous and can produce message storms during a noisy event.
Suppression is final for that channel.
It is a policy result, not a transient failure. Do not hammer the SMS channel; move to the permitted email fallback and record sms_suppressed as the transition reason. SMS resend flows exist, but the application should rate-limit them by account and destination and prevent concurrent resend jobs.
Email adds richer content, templates, and a secondary audit trail. It does not provide a hosted OTP endpoint in this capability, so an email verification fallback must use the game's own verification token. Scheduled email has no cancellation route, while SMS does have cancellation support; avoid scheduling a fallback that your worker cannot revoke after SMS succeeds.
Ship with an evidence review
Before launch, read one complete signup history as if the provider dashboard were unavailable. It should show the policy version, destination region, stable attempt ID, idempotency keys, provider message IDs, poll observations, fallback deadline, and transition reasons. Confirm that raw verification tokens and unnecessary destination data are absent from long-lived logs. Then replay the same job and verify that no second logical message appears.
Review the worker cadence against the signup deadline, because pull-based status limits reaction time. Put alerts on stuck sms_wait and email_wait records, but do not turn every pending poll into an incident. Finally, review country allowlists and budget guards as application policy. The provider cannot make those decisions for you.
The durable design is small: policy first, SMS attempt, observed result, explained transition, email fallback. Once those facts are yours, vendor replacement becomes an adapter change instead of an audit rewrite.
Further reading
- Twilio SMS documentation: https://www.twilio.com/docs/sms
- Amazon SNS SMS documentation: https://docs.aws.amazon.com/sns/latest/dg/sns-mobile-phone-number-as-subscriber.html
- Vonage SMS API overview: https://developer.vonage.com/en/messaging/sms/overview
- SendGrid email API documentation: https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send
- RFC 8058, one-click unsubscribe: https://datatracker.ietf.org/doc/html/rfc8058
Top comments (0)