DEV Community

OwenSullivan9135
OwenSullivan9135

Posted on

Webhook-Free SMS Verification Explained: Polling State, Retry UX, and Abuse Controls

A healthtech workflow that generates a clinical report and sends it as an email attachment already has one uncertain delivery channel. Adding SMS OTP login creates another, and a provider without webhooks moves the reconciliation work into your application. The design choice is therefore less about callback preference than about how much ambiguous state your team is prepared to own.

Short answer: in a callback-free design, polling reconciles message delivery, code verification remains synchronous and authoritative, resend becomes a transition in a server-side challenge state machine, and rate limits cover the person, destination, device, and network rather than one button. This design has a lower inbound integration burden when a provider exposes a stable status lookup; delayed delivery events make it unsuitable for workflows in which support, routing, or compliance automation must react immediately.

Delivery is not verification.

A carrier can accept a message that the user never reads, while a user can submit a valid code before a delivery-status poll observes its final state. If those facts share one Boolean such as otp_sent, retries become guesswork and support logs become fiction.

That distinction matters even more around a generated health report. The report-email job, the SMS delivery attempt, and the login challenge may share a correlation identifier, but none should confer success on either of the others. Keep them separate.

How should SMS verification status polling work without webhooks?

Model the challenge first, then adapt the provider to it. The application owns whether a challenge may be verified; the SMS gateway owns only what it knows about message submission and delivery. Polling copies the latter into an observation field. It must never turn PENDING into VERIFIED.

A compact state model can look like this:

State Meaning Allowed next action Failure mode it prevents
CREATED Challenge exists; no send has been accepted Attempt one send A UI claiming that a message exists before submission
CODE_PENDING A send was accepted and the code may be submitted Verify, poll delivery, or request resend Treating provider acceptance as proof of receipt
VERIFIED The server matched a live challenge and consumed it Establish the authenticated session Reusing a code after success
EXPIRED The challenge lifetime ended Start a new challenge, subject to limits Extending a secret forever through repeated sends
LOCKED Attempt or abuse policy stopped the challenge Wait or use an approved recovery path Online guessing and resend amplification

Store delivery as an observation beside this state: provider message reference, last known delivery category, observation time, poll count, and the next eligible poll time. Status can arrive late or remain inconclusive. That is ordinary distributed-system uncertainty — not evidence that a submitted code is wrong.

Never infer it.

The browser should poll your application, not the SMS provider. Your server can return the challenge state and a coarse delivery category, while a worker performs any provider lookup under a bounded schedule. This keeps credentials off the client, centralizes rate limits, and stops every open tab from multiplying outbound status requests. A practical schedule might check after 2, 5, 15, and 30 seconds, then stop; those numbers are an example policy, not an industry guarantee, and production values should come from observed provider latency and the provider's documented request limits.

I'm not sure a final delivery event will exist for every destination because that depends on the selected gateway, route, carrier, and country. Resolve that uncertainty during evaluation: ask for the exact terminal statuses, retention window, lookup limits, and meaning of an unknown result. If the contract cannot answer those questions, the UI must remain honest: "Code sent" after submission acceptance, then "Try another method" after the local wait budget, never "Delivered" based on elapsed time.

Separate authentication from report delivery

The concrete workflow has three records: report_delivery, login_challenge, and message_attempt. Link them with an internal correlation ID for tracing, but give each its own lifecycle and retention policy. An accepted report email does not prove that the recipient opened the attachment. An accepted SMS does not prove that the phone is in the recipient's hand. A successful OTP proves only that the submitted code matched the live server-side challenge under the policy you enforced. This separation also clarifies retries. Retrying the report email should reuse the already generated report artifact unless product requirements explicitly call for regeneration. Retrying an SMS send should create a new message attempt under the same challenge or replace the challenge, depending on the code policy. Verifying a code should never trigger another report send. Without those boundaries, a user who taps resend during a slow carrier interval can accidentally create duplicate report notifications, and an operations team cannot tell whether it is looking at an authentication problem or a document-delivery problem.

There is a security catch in the scenario itself: an OTP-protected portal does not protect a sensitive attachment that has already been placed in an inbox. If the attachment contains information that should require an authenticated session, send a notification and a short-lived portal link instead of the report bytes, or encrypt the document under a separately delivered secret after an explicit threat review. If direct attachment delivery is a firm requirement, document that its access model is email-account access; don't imply that the later SMS challenge retroactively controls it.

Email and SMS still belong in the same trace. Record immutable identifiers and timestamps, avoid storing the OTP or full message body in logs, and expose a support view that can answer three independent questions: was the report job submitted, what was last observed about the SMS attempt, and was the challenge verified? Amazon SES documentation is useful evidence for the email side of this boundary, while the authentication state remains an application concern.

Make the state transition atomic

The following Python sketch is deliberately transport-neutral. A Node.js service can implement the same transitions in its existing framework; the important integration surface is the gateway contract and atomic persistence, not the language used in this example. No provider route is assumed.

from dataclasses import dataclass, replace
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Protocol


class ChallengeState(str, Enum):
    CODE_PENDING = "code_pending"
    VERIFIED = "verified"
    EXPIRED = "expired"
    LOCKED = "locked"


@dataclass(frozen=True)
class Challenge:
    challenge_id: str
    state: ChallengeState
    code_digest: bytes
    expires_at: datetime
    verify_attempts: int
    send_attempts: int
    message_ref: str
    delivery_category: str
    next_poll_at: datetime


class SmsGateway(Protocol):
    def lookup(self, message_ref: str) -> str: ...
    def resend(self, challenge_id: str) -> str: ...


def observe_delivery(
    challenge: Challenge, gateway: SmsGateway, now: datetime
) -> Challenge:
    if challenge.state is not ChallengeState.CODE_PENDING:
        return challenge
    if now < challenge.next_poll_at:
        return challenge

    observed = gateway.lookup(challenge.message_ref)
    return replace(
        challenge,
        delivery_category=observed,
        next_poll_at=now + timedelta(seconds=30),
    )


def request_resend(
    challenge: Challenge, gateway: SmsGateway, now: datetime
) -> Challenge:
    if challenge.state is not ChallengeState.CODE_PENDING:
        raise ValueError("challenge is not active")
    if now >= challenge.expires_at:
        return replace(challenge, state=ChallengeState.EXPIRED)
    if challenge.send_attempts >= 3:
        return replace(challenge, state=ChallengeState.LOCKED)

    message_ref = gateway.resend(challenge.challenge_id)
    return replace(
        challenge,
        send_attempts=challenge.send_attempts + 1,
        message_ref=message_ref,
        delivery_category="submitted",
        next_poll_at=now + timedelta(seconds=2),
    )
Enter fullscreen mode Exit fullscreen mode

The sample limit of three sends is illustrative. In a real service, request_resend must be one atomic operation across challenge storage and the outbox: reserve the attempt, enqueue one send with an idempotency key, and commit before returning. If a process restarts between the gateway call and the database write, an uncoordinated implementation can send twice while counting once. An outbox worker and a provider-supported idempotency mechanism, where available, narrow that gap.

Verification needs the same discipline. Compare a digest with constant-time primitives, consume the challenge atomically on success, count failed submissions, and avoid revealing whether a phone number is registered. Don't let a delivery result reset the verification counter. The short code, destination, and raw provider response also do not belong in general application logs; retain only what incident response and support actually need.

What should the retry UX and abuse policy expose?

Start with one visible resend control and one countdown derived from server time. Disabling the button only in the browser is decoration: scripts and parallel tabs ignore it. The server decides eligibility and returns a stable reason category plus the next eligible time. Keep error text useful but coarse, such as "Please wait before requesting another code" or "Use a different sign-in method."

For input ergonomics, use the platform's one-time-code autofill facilities instead of inventing a six-box widget that breaks paste, accessibility tools, or password-manager behavior. Apple's Password AutoFill documentation is the primary reference for its supported flow. The form should still accept normal typing and paste, preserve leading zeroes as text, submit once, and let the server reject an expired or consumed challenge. Autofill helps the happy path; it does not change the trust boundary.

Abuse prevention needs several overlapping counters because each single key has an obvious blind spot. A per-destination limit slows harassment of one phone number. A per-account limit covers number changes. Device or session limits constrain repeated anonymous attempts. Network-level controls catch broad automation, though shared networks make hard blocking risky. Finally, a global circuit breaker caps spend and downstream load during an attack. Exact thresholds depend on traffic distribution, recovery options, and false-positive tolerance; your mileage may vary, so ship them as observable policy rather than constants buried in a route handler.

Resend should usually preserve a clear rule about which code is valid. Either keep one code for the challenge and issue multiple delivery attempts, or invalidate the old challenge and create a new one. Both models have trade-offs. A stable code reduces the late-message problem but gives an attacker more time against one secret; rotating codes shortens that continuity but users may enter an older SMS that arrived last. The UI copy, verification storage, and support tooling must all agree with the chosen model.

Choose by operational ownership, then roll out narrowly

Approach Integration effort Best fit Not suitable when
Hosted verification with status lookup Lower application surface; provider owns code generation and message formatting A small team wants a narrow authentication contract You require custom code lifecycle semantics or immediate event-driven delivery handling
Direct SMS with an application-owned challenge Higher effort across secret storage, verification, retries, and abuse controls Existing identity infrastructure already owns those controls The team cannot operate an authentication state machine and its incident path
Event-capable verification More callback authentication, replay protection, and event storage Delivery transitions must quickly trigger routing or support automation Inbound endpoint operations cost more than the event latency is worth
Non-SMS recovery method Separate enrollment and recovery work Users may lose cellular access or need an accessible alternative It is treated as an unprotected bypass rather than an equally governed factor

The catch is that polling trades inbound integration work for delayed knowledge and recurring outbound work. Stick with callback delivery when fresh events drive consequential automation. Choose polling when delivery state is advisory, lookup semantics are documented, and the bounded lag is acceptable. Direct SMS is not suitable when the team wants low integration effort but has no mature secret-handling and abuse-control layer; hosted verification is the cleaner boundary in that case.

Roll out in a shadowed sequence. First, create the challenge and message-attempt records while the current login path remains authoritative. Next, reconcile delivery statuses without exposing them to users, measuring how often status remains unknown and how much polling occurs per challenge. Then enable resend policy for a small traffic slice and alert on send attempts per successful verification, verification failures, challenge locks, and report-email duplication. Finally, test process restarts, concurrent resends, late SMS arrival, expired codes, and recovery access before expanding traffic.

No single delivery metric decides success. The useful review combines authentication completion, time to verification, resend frequency, lock rate, provider status age, support contacts, and duplicate notification count. A provider can report attractive delivery numbers while the login UX still fails users — and an easy login can still be unsafe if one actor can trigger thousands of sends.

References

Top comments (0)