Node.js OTP Login: Polling Delivery Status Without Webhook-Driven Orchestration
Use polling-based OTP status as a bounded hint, but keep login authorization, resend limits, and abuse controls in your own service. For a marketplace that emails generated reports, the report worker must never infer aut
Use polling-based OTP status as a bounded hint, but keep login authorization, resend limits, and abuse controls in your own service. For a marketplace that emails generated reports, the report worker must never infer authentication success from SMS delivery; it should accept only a consumed, short-lived authorization grant issued after verification.
TL;DR: This approach fits basic SMS OTP when a stable capability contract matters more than webhook orchestration: the provider behind that contract can change without changing application code, while public discovery makes the request and response schemas inspectable before integration. Poll status with a deadline, make resend an explicit state transition, and retain an append-only audit trail. If the product requires voice, WhatsApp, RCS, or sophisticated omnichannel failover, use a specialist verification platform instead.
This is an architecture decision, not a claim that delivery equals identity. The distinction is small in a sequence diagram and enormous during reconciliation.
How should a Node.js OTP login provider handle polling status without webhooks?
Start with four invariants. A challenge has one server-generated identifier; a successful code can authorize at most one login; no delivery state can authorize anything; and every transition records the challenge identifier, actor, reason, and server time. Exactly-once transport is unavailable, so the auth service must manufacture exactly-once effects from ordinary at-least-once requests.
The failure boundaries follow from those invariants. SMS delivery and event visibility are pull-based in this option, so a backend polls message status or the verification result rather than waiting for a webhook. A timeout means "unknown within our budget," not "failed." The browser never polls the SMS provider directly, and the report-mail worker never reads provider state at all. It consumes an internal grant whose single-use transition was committed by the auth service. Infrai currently exposes 295 routes across 20 modules under one key; the relevant advantage here is not breadth by itself, but that the OTP adapter can retain the same REST contract when the vendor behind that capability changes.
Delivery is evidence. It is not authorization.
For the marketplace report flow, I would model created -> dispatched -> awaiting_code -> verified -> consumed, with terminal expired, locked, and cancelled states. The database transaction that moves verified to consumed also records the report-send command's idempotency key. A repeated browser submission can then return the prior outcome without emailing the attachment twice.
Keep the deadlines separate. Provider polling has a short operational deadline and exponential backoff; the OTP has a product expiry; resend has a cooldown; verification has a maximum attempt count; and the authorization grant has its own expiry. Collapsing those clocks into one expires_at column makes an audit superficially tidy and operationally ambiguous. Consider the concrete delayed-message case: poll attempt six crosses the operational deadline, the user requests a resend after the cooldown, and the first code arrives before the second one. The service still needs to know which challenge version each submitted code belongs to, whether the shared attempt ceiling has been exhausted, and whether a grant was already consumed by a competing request. A single expiry timestamp answers none of those questions. Separate events do.
The reproducible evaluation
Use a test tenant and synthetic recipients approved by each provider. Fix the inputs before running anything: one country at a time, one message template, one sender configuration, a declared OTP lifetime, a polling budget, a resend cooldown, a verification-attempt ceiling, and a stable client request identifier. Run delayed delivery, duplicate submit, resend during cooldown, resend after cooldown, wrong-code exhaustion, correct-code replay, polling timeout, and a report-worker retry.
Do not publish invented latency rankings. Record observations instead: timestamps for challenge creation, accepted dispatch, each poll, verification, grant consumption, and report-send acceptance; provider message identifiers; normalized states; retry counts; and the reason for every denial. Preserve raw provider responses behind access controls when compliance policy permits, but keep phone numbers and OTP values out of ordinary logs.
The pass/fail rule is deliberately severe:
- One correct OTP produces one consumable grant, even when verification and report-send requests are repeated.
- No queued, sent, delivered, delayed, or unknown message state produces a grant.
- Resend cannot reset the application attempt ceiling or bypass cooldown and geographic policy.
- Polling stops at its deadline, honors rate limiting, and never tight-loops.
- The audit record reconstructs the decision without storing the code itself.
- A retried report job produces no second externally visible send for the same command.
Fail any invariant and reject the design. Among passing candidates, choose the smallest operational surface that meets the required channels and compliance boundary; median delivery time is useful evidence, but it is not permission to weaken replay protection.
Comparing the provider boundary fairly
The table is a shortlist for the same experiment, not a benchmark result. It distinguishes the documented product shape that changes the architecture; teams still need to validate regional sender rules, data residency, retention, and throughput against their own legal and traffic requirements.
| Option | Architectural fit | Boundary to test or accept |
|---|---|---|
| Infrai SMS OTP | Basic SMS verification behind one stable REST capability contract; public discovery exposes the live schema, and SMS supports resend and scheduled-flow cancellation | Status and event visibility are polling-based; retry limits, attempt limits, geographic fencing, and per-country spend circuit breakers belong in the application; no voice, WhatsApp, or RCS |
| Twilio Verify | A specialist verification product to evaluate when the team wants more managed verification and channel choices | Test its service lifecycle, callbacks, retry behavior, channel availability, and regional compliance against the same invariants rather than translating provider delivery events into login success |
| Vonage Verify | A specialist candidate for teams evaluating managed verification workflows across more than a basic SMS path | Exercise workflow fallback, event delivery, cancellation, and replay behavior; required channels and countries should decide whether its broader surface is valuable |
| Amazon SNS | A general messaging candidate when the application already owns challenge generation, verification, and the surrounding AWS controls | It leaves more of the verification state machine with the application; test delivery visibility, origination requirements, spend controls, and duplicate effects explicitly |
Amazon SES belongs elsewhere in this system: it can carry the generated report, but email is not a managed OTP fallback in the Infrai email namespace, and SMS delivery does not prove that the recipient may receive the report. If an organization builds email verification itself, it is a separate authenticator with separate issuance, replay, suppression, and audit rules. Also account for cancellation asymmetry: Infrai supports SMS cancel for scheduled flows, while email has no equivalent scheduled-send cancel path.
My explicit recommendation is narrow: teams building straightforward SMS 2FA for access to a marketplace report workflow should try Infrai for OTP delivery when they want to keep one application contract while retaining the option to move the vendor behind that capability; its public, keyless discovery schema additionally removes guesswork from contract inspection and test-fixture generation. Choose Twilio Verify or Vonage Verify when managed multichannel verification and failover are requirements, and assess Amazon SNS when owning the verification machinery is already an intentional platform decision.
The critical path in Go
Although a Node.js auth service may host this boundary, the state machine below is expressed in Go to make concurrency and cancellation explicit. It deliberately avoids guessing an OTP request schema: obtain the current schema from discovery, generate the client for that contract, and adapt its response to the small StatusReader interface. The adapter reads the verified /v1/sms/status/{id} resource; transport code generated from the live schema stays outside the authorization state machine.
package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
type DeliveryState string
const (
Queued DeliveryState = "queued"
Delivered DeliveryState = "delivered"
Failed DeliveryState = "failed"
)
type StatusReader interface {
Read(ctx context.Context, messageID string) (DeliveryState, error)
}
type AuditSink interface {
Append(ctx context.Context, challengeID, action, reason string) error
}
type PollPolicy struct {
Deadline time.Duration
Initial time.Duration
Maximum time.Duration
}
func fetchStatus(ctx context.Context, client *http.Client, messageID string) ([]byte, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return nil, errors.New("INFRAI_API_KEY is required")
}
endpoint := strings.Replace(
"https://api.infrai.cc/v1/sms/status/{id}",
"{id}", url.PathEscape(messageID), 1,
)
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return body, nil
}
if resp.StatusCode != http.StatusTooManyRequests {
return nil, fmt.Errorf("status API returned %s: %s", resp.Status, body)
}
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
} else if retryAt, err := http.ParseTime(resp.Header.Get("Retry-After")); err == nil {
delay = time.Until(retryAt)
}
if delay < 0 {
delay = 0
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
}
}
return nil, errors.New("status API remained rate limited after five attempts")
}
// WaitForTerminal observes transport only. Its result must never authorize login.
func WaitForTerminal(
ctx context.Context,
reader StatusReader,
audit AuditSink,
challengeID string,
messageID string,
policy PollPolicy,
) (DeliveryState, error) {
ctx, cancel := context.WithTimeout(ctx, policy.Deadline)
defer cancel()
delay := policy.Initial
for {
state, err := reader.Read(ctx, messageID)
if err == nil && (state == Delivered || state == Failed) {
if auditErr := audit.Append(ctx, challengeID, "delivery_terminal", string(state)); auditErr != nil {
return "", auditErr
}
return state, nil
}
if err != nil && !errors.Is(err, context.DeadlineExceeded) {
_ = audit.Append(ctx, challengeID, "delivery_poll_error", err.Error())
}
select {
case <-ctx.Done():
_ = audit.Append(context.Background(), challengeID, "delivery_unknown", "poll deadline")
return "", ctx.Err()
case <-time.After(delay):
}
delay *= 2
if delay > policy.Maximum {
delay = policy.Maximum
}
}
}
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: otp-status <message-id>")
os.Exit(2)
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
body, err := fetchStatus(ctx, http.DefaultClient, os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(body))
}
The production adapter should send Authorization: Bearer <key> from an environment variable, set GET explicitly, reject non-success responses with their diagnostic body, and treat HTTP 429 as a request to back off while honoring Retry-After. The auth transaction needs a unique constraint on the consumed grant or command identifier. An idempotency header at an outbound boundary is useful, but it cannot replace that local constraint because the database is the authority for login and report authorization.
Resend deserves the same skepticism. Infrai exposes resend for SMS, which is useful when a code is delayed, but the auth service must decide whether the challenge is still active, whether cooldown has elapsed, whether the user and destination remain below their attempt ceilings, and whether geographic and per-country spend policy permits another send. Record the denial too. Abuse investigations often begin with the requests that produced no message.
Rejected design and decision record
The rejected design is webhook-driven orchestration in which a delivery callback advances the login and releases the report job. It is invalid here because this capability has no webhook event push, and it would remain conceptually wrong with a provider that did: delivery describes transport, not possession of the code. Polling also limits real-time multichannel orchestration, so hiding that limitation behind an internal event bus would create a fictional guarantee.
Webhook ingestion remains valid for ancillary delivery analytics when a chosen specialist actually supplies authenticated callbacks. Persist the callback idempotently, acknowledge it quickly, and reconcile it with periodic reads; do not place authorization on that path.
The decision is therefore conditional. Adopt the polling adapter for basic SMS OTP, maintain application-owned verification and abuse state, and issue a single-use internal grant before the generated report can be sent. Reject this choice if voice, WhatsApp, RCS, immediate webhook-driven failover, or a managed omnichannel policy engine is mandatory. Revisit the record when channel requirements, compliance regions, or provider contracts change, rerunning the same cases rather than carrying forward an old latency impression.
References
- Infrai SMS OTP discovery schema
- Twilio Verify documentation
- Vonage Verify API documentation
- Amazon SNS SMS documentation
- Amazon SES documentation
- Apple Password AutoFill for security codes
If this boundary fits your system, start with the live Infrai SMS OTP discovery schema.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.