TL;DR: For a B2B SaaS login without webhooks, keep the signup verification message in the application repository when product engineers own the flow, then use bounded polling for SMS OTP delivery status. Treat that status as operational evidence, not proof that a person received or read the code. Stop querying, let the code-entry screen remain usable, and offer a controlled resend when the window ends.
| Template owner | Pick this when | Main trade-off | Status strategy |
|---|---|---|---|
| Application team | Copy and authentication behavior ship together | Content changes require an application release | Bounded query loop tied to an internal message ID |
| Communications platform team | Many applications share policy and localization | Product copy can drift away from UI behavior | Central status adapter with per-flow time budgets |
| External delivery service | The message is stable and operations owns changes | Runtime configuration sits outside normal code review | Query through an internal boundary; keep the service response out of the browser |
The least complex fit for one signup flow is usually application ownership. The page, expiry copy, resend rules, event names, and tests change in one review. A central team becomes useful when the same verification language spans several products or regulated regions. Service-owned templates fit stable messages, but they move a security-sensitive artifact into a second deployment system.
Ownership comes first.
Which team should own the verification message?
Choose the owner that can keep the promise in the message aligned with the authentication state machine. For a single B2B SaaS application, that is often the application team. It can review the SMS text beside the handler that issues the challenge and the screen that accepts it. The boundary matters more than the storage location. A template contains user-facing claims: why the code arrived, where it works, and what the recipient should do if they did not request it. OWASP recommends consistent responses, side-channel delivery, secure random tokens or codes, secure storage, single use, and expiration for password-recovery flows. Those controls are also useful design constraints for verification messages, but a delivery-state query does not implement them. Authentication logic still owns challenge validity. The trade-off is plain: application ownership keeps behavior and copy together, while shared ownership reduces duplicated policy work across products.
Pick communications-team ownership when policy, translation, accessibility review, or brand review genuinely crosses applications. Give that team a versioned template contract: required variables, maximum supported substitutions, the owning flow, and a review path for behavioral copy. Otherwise centralization adds a queue without adding control.
Pick service ownership when operations already manages stable transactional content and changing it independently is valuable. Do not let a browser call the delivery service directly. Credentials, provider-shaped states, and message identifiers belong behind an application boundary.
Should login UX poll SMS OTP delivery status without webhooks?
Yes, when the active screen needs brief operational feedback and no webhook receiver exists. But delivery status says very little about the human. It can tell the system that a delivery attempt is still progressing, reached a terminal success state reported by the transport, or reached a terminal failure state. It cannot establish that the intended person possesses the phone, saw the message, or entered the right code. The tempting assumption is that delivered means “continue login.” It does not.
That distinction prevents a common UX mistake: advancing authentication because transport status looks successful. The only success condition for the login flow is valid proof presented to the application. Delivery telemetry merely changes the help text and determines when a retry option is reasonable.
Use a small internal vocabulary. Three states are enough for the UI: pending, delivered, and failed. Preserve richer transport details in server-side logs if they are useful, but map them before they cross the boundary. A diagram in words looks like this: browser asks the application; application looks up its own message record; a server-side adapter asks the transport; the adapter normalizes the answer; the browser updates guidance, never authentication state.
Keep regions boring. The same state machine can serve US and EU users while retention, access, and message-copy policies remain configuration owned by the appropriate teams. Do not put phone numbers, OTP values, or full message bodies into telemetry merely because querying makes events easy to emit.
Implement one bounded status loop
The browser needs an opaque application message ID, not a transport credential or raw provider object. The server returns a normalized state. The client waits between checks, stops after a fixed number of attempts, and stops immediately on either terminal state.
Here is the deep implementation. The numbers are product choices, not delivery guarantees: six checks, starting after 2 seconds, with a maximum 8-second interval. The increasing delay avoids a tight loop while still giving the first few seconds more attention.
type DeliveryState = "pending" | "delivered" | "failed";
type StatusResponse = {
state: DeliveryState;
checkedAt: string;
};
const wait = (milliseconds: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
async function watchDelivery(
messageId: string,
signal: AbortSignal,
onChange: (state: DeliveryState) => void,
): Promise<DeliveryState> {
const maximumAttempts = 6;
for (let attempt = 0; attempt < maximumAttempts; attempt += 1) {
const delayMs = Math.min(2_000 * 2 ** attempt, 8_000);
await wait(delayMs);
if (signal.aborted) return "pending";
const response = await fetch(
`/auth/messages/${encodeURIComponent(messageId)}/status`,
{ signal, headers: { accept: "application/json" } },
);
if (!response.ok) {
// A transient lookup error must not invalidate an active OTP challenge.
continue;
}
const result = (await response.json()) as StatusResponse;
onChange(result.state);
if (result.state !== "pending") return result.state;
}
return "pending";
}
Abort the loop when the user leaves the page or submits a valid code. A failed status lookup should leave the authentication challenge alone; the two lifecycles are separate. Likewise, reaching the attempt limit means “no newer transport evidence,” not “the SMS failed.”
Words matter.
The server-side adapter should enforce authorization for every lookup, map transport-specific values to the three-state contract, and rate-limit reads. Associate the opaque ID with the current signup or login session. A caller must not be able to enumerate another account's message activity.
Instrument the transitions, not every timer tick. Useful events include challenge issued, first status observed, terminal status observed, polling budget exhausted, resend requested, and challenge verified. Attach a correlation ID and coarse region. Keep secrets and message content out. This produces a crisp funnel without turning logs into a second database of authentication material.
Test the gaps between transport and authentication
A happy-path unit test is insufficient. Use a fake status adapter and a fake clock so the suite remains deterministic. Cover pending-to-delivered, pending-to-failed, repeated lookup errors, cancellation during a wait, and exhaustion while the OTP remains valid. Also assert that delivered never submits or verifies a challenge.
Then test the ownership boundary. A template change that removes a required variable should fail before deployment. A localization test should catch missing variants. An integration test should confirm that the browser receives only the normalized state and opaque ID.
There is a useful before and after here. Before normalization, UI code tends to accumulate transport vocabulary and branch on values that have no authentication meaning. After normalization, the page has three display choices, while server logs retain the detail needed for diagnosis. Smaller surface. Better signals.
For alerts, favor user-impact ratios over individual failures: terminal failures compared with issued challenges, verification completions compared with challenges, and unusual resend growth. Define thresholds from observed baselines and traffic volume; a universal number would be invented precision. Split dashboards by coarse region only when that view helps an on-call engineer act.
Limits of polling without webhooks
Polling creates delayed knowledge and repeated reads. A short budget is appropriate when status only improves guidance during an active screen. It is a poor fit for long-running delivery reconciliation, high-volume analytics, or workflows that must react after the browser closes. Those need an asynchronous ingestion path or later reconciliation job.
Do not promise “real time.” The UI is seeing sampled transport state. Also avoid coupling resend directly to a failed state: failure labels can arrive at different times, while resend limits exist to protect both the user and the authentication flow. Let a server-side policy decide eligibility.
The durable rule is simple: template ownership follows responsibility for the message's behavioral promise; authentication success follows code verification; transport status remains bounded, normalized telemetry. That division keeps a responsive login screen from quietly becoming a second source of truth.
Top comments (0)