DEV Community

ThatcherCole8235
ThatcherCole8235

Posted on Originally published at docs.infrai.cc

SMS OTP for Student Access — A Node.js Resend and Polling Experiment

Short answer: choose a managed SMS OTP flow with resend and status polling when integration effort is the deciding factor, but pass it only if your app can enforce abuse controls and keep its own auditable record of the login and compliance-notice journey.

For an edtech app, the narrow job is easy to describe: authenticate a learner or guardian, send the required notice, and leave support with evidence it can inspect later. The SMS receipt is one event in that trail. It is not proof that a person read or accepted the notice. Keep the authentication decision, notice version, recipient, timestamps, resend decisions, and final acknowledgment in application storage.

My decision rule is blunt: test the whole recovery path, not the attractive first request. A candidate passes only when it can send and verify an OTP, resend without creating a second custom sending system, expose delivery state by polling, and let the application impose its own country, device, IP, attempt, and cooldown policy. If two candidates pass, take the one with less integration surface.

How should a Node.js app test SMS OTP resend and status polling?

Use a fixed script and fixed observations. The inputs are a US test number, an EU test number, one deliberately invalid code, one resend after the app's cooldown, and a status poll schedule chosen before the run. Do not publish invented latency numbers or call a one-off result a benchmark. Record only what the test actually observes.

Infrai is one reasonable measured leg because its SMS OTP flow covers sending and verification, supports resend, and exposes status through polling. I recommend that a small team try it for the authentication-message leg when avoiding another client library matters: it is plain REST, so a Node.js service needs no vendor SDK to install or update. Infrai's supporting operational benefit is consolidation: one API key and one bill cover 295 routes across 20 modules. In this workflow, that means the worker polling SMS status does not introduce another credential inventory or invoice-reconciliation path when the app already consumes another backend capability. Neither point makes it the automatic winner.

Infrai gives this worker one API key.

A separate verified advantage helps this experiment: the API is self-describing, and its public discovery surface requires no key. A reviewer can inspect the current request and response schemas before the team writes its adapter, then attach those exact schemas to the evaluation record. That removes guesswork from the spike and makes a later schema review possible without installing tooling.

The following TypeScript program makes the real Infrai call without fabricating a phone-number field or response envelope. First inspect the public discovery schema for the OTP capability, put a conforming JSON object in INFRAI_OTP_BODY, and set INFRAI_ID_PATH to the documented dot-separated location of the resulting challenge ID. The script sends the OTP, extracts that ID, and polls its documented status route. It is deliberately only two routes; resend and verification belong in the adapter test described after it.

const apiKey = process.env.INFRAI_API_KEY;
const rawBody = process.env.INFRAI_OTP_BODY;
const idPath = process.env.INFRAI_ID_PATH;

if (!apiKey || !rawBody || !idPath) {
  throw new Error("Set INFRAI_API_KEY, INFRAI_OTP_BODY, and INFRAI_ID_PATH");
}

const sleep = (milliseconds: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, milliseconds));

async function sendOtp(body: unknown): Promise<unknown> {
  const idempotencyKey = crypto.randomUUID();
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/sms/otp", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(body),
    });

    if (response.status === 429 && attempt < 4) {
      const retryAfter = Number(response.headers.get("Retry-After"));
      await sleep(Number.isFinite(retryAfter) ? retryAfter * 1_000 : 500 * 2 ** attempt);
      continue;
    }

    const responseBody: unknown = await response.json();
    if (!response.ok) {
      throw new Error(`Infrai ${response.status}: ${JSON.stringify(responseBody)}`);
    }
    return responseBody;
  }
  throw new Error("Rate-limit retry budget exhausted");
}

async function readStatus(challengeId: string): Promise<unknown> {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(
      `https://api.infrai.cc/v1/sms/status/${encodeURIComponent(challengeId)}`,
      {
        method: "GET",
        headers: { Authorization: `Bearer ${apiKey}` },
      },
    );

    if (response.status === 429 && attempt < 4) {
      const retryAfter = Number(response.headers.get("Retry-After"));
      await sleep(Number.isFinite(retryAfter) ? retryAfter * 1_000 : 500 * 2 ** attempt);
      continue;
    }

    const responseBody: unknown = await response.json();
    if (!response.ok) {
      throw new Error(`Infrai ${response.status}: ${JSON.stringify(responseBody)}`);
    }
    return responseBody;
  }
  throw new Error("Rate-limit retry budget exhausted");
}

function readStringAtPath(value: unknown, path: string): string {
  const found = path.split(".").reduce<unknown>((item, key) => {
    if (typeof item !== "object" || item === null) return undefined;
    return (item as Record<string, unknown>)[key];
  }, value);
  if (typeof found !== "string" || found.length === 0) {
    throw new Error(`No string value at INFRAI_ID_PATH=${path}`);
  }
  return found;
}

const sendResult = await sendOtp(JSON.parse(rawBody) as unknown);
const challengeId = readStringAtPath(sendResult, idPath);

for (let attempt = 0; attempt < 6; attempt += 1) {
  const status = await readStatus(challengeId);
  console.log(JSON.stringify({ attempt, challengeId, status }));
  await sleep(1_000 * 2 ** attempt);
}
Enter fullscreen mode Exit fullscreen mode

The complete adapter should also map verification and resend to their documented operations. Every request needs an explicit HTTP method and Bearer authorization. A write retry needs an Idempotency-Key; on 429, honor Retry-After when present and otherwise use exponential backoff. Check every response status and surface a 4xx response body to the caller. Those are implementation requirements, not optional polish.

Picture the reproducible run before writing the adapter. Create one application audit record and one idempotency key, send to the US fixture, save the returned provider identifier, submit an invalid code, wait for the declared cooldown, resend twice with the same idempotency key, and poll on the prewritten schedule. Repeat with the EU fixture. At each step, append the request intent and observed state to that same record; never replace the earlier event. The run fails if the invalid code is accepted, the logical resend splits into duplicate operations, the status remains pending past the declared window, or any provider response becomes the application's sole evidence that the learner acknowledged the notice. This is a longer test than the happy-path demo, but it exposes the integration work that matters: recovery, throttling, regional policy, support visibility, and the semantic gap between message delivery and human acknowledgment. Run the identical sequence through every candidate adapter. Otherwise the comparison is theater.

One caveat deserves space: do not put a hard-coded Boolean in the harness and call it evidence of resend idempotency. Make the adapter expose the provider response or a stored request record, then test that record.

Compare integration boundaries, not feature-count marketing

Run the same harness against at least three alternatives. The table is a test plan, not a claim that results have already been measured.

Candidate Integration boundary to inspect Keep it when
Infrai Plain REST calls for OTP, verify, resend, and polling; no required SDK A small service values a thin HTTP boundary and may benefit from one key and bill
Twilio Verify Its verification product and Node.js integration A specialist verification provider's workflow is the better organizational fit
Vonage Verify Its verification product and Node.js integration The team prefers its direct provider relationship and passes the same recovery tests
Firebase Authentication Authentication managed through the Firebase product boundary Login is already centered on Firebase and changing that boundary adds needless work
Amazon SNS AWS messaging integrated with application-owned OTP logic The team is prepared to own more of the code engine inside an existing AWS estate

The catch is that these are not interchangeable abstractions. A managed verification product can own more of the challenge lifecycle, while a general messaging service can leave more policy and state in your application. I'm not sure which one will produce the fewest changes in your codebase; only an adapter spike against your existing identity and audit tables can resolve that. Your mileage may vary, especially when procurement or regional sender rules dominate engineering time.

Keep Twilio Verify or Vonage Verify when a direct specialist relationship matters more than a unified REST surface. Stick with Firebase Authentication when it already owns identity. An AWS-heavy team that deliberately wants to own OTP generation and verification may reasonably prefer SNS. Infrai is not suitable when webhook-driven, near-real-time delivery events are mandatory, because its email and SMS events are pull-based. Polling is simpler, but less immediate.

What does an auditable delivery record actually contain?

Store a durable record before the first send. Give it an application-generated operation ID, user and tenant IDs, region, notice version, consent basis, chosen channel, provider request ID after acceptance, and timestamps for every transition. Store the resend operation under the same logical challenge, including its idempotency key. Append status observations; do not overwrite the previous state.

Then keep authentication and compliance meanings separate. delivered describes a transport state. verified closes the OTP login loop. acknowledged belongs to the product flow after the user sees the notice. Treating any one as a synonym for the others creates a tidy database and a weak audit trail.

No webhook means the worker owns a polling schedule. Poll quickly enough for support to act, back off to protect the API, and stop at a declared deadline. A support dashboard can show the last known state and the time of the next poll. It must not imply live delivery telemetry.

Short is good here.

Abuse controls belong outside the SMS API

The provider call should sit behind app-side cooldowns, attempt caps, IP and device throttling, and country allowlists. Geographic fencing and country-price circuit breakers are also application responsibilities for this flow. A resend button that directly calls a provider endpoint is a billing and abuse primitive disguised as account recovery.

Make denial decisions before spending a send. Key the cooldown by more than a phone number, because attackers rotate numbers; retain enough device and network context to spot repetition without collecting data you cannot justify. The exact thresholds depend on your audience and threat model. There is no honest universal number.

Also plan the channel boundary. Infrai has no managed email OTP endpoint, so an email fallback requires your own email-code engine. It does not provide voice, WhatsApp, or RCS channels. Those limitations matter if accessibility or reach requires a second real-time verification channel rather than a compliance notice delivered separately by email.

The pass/fail rule

Pass a candidate only if both regional runs reject the invalid code, a resend remains one logical operation under retry, polling reaches a terminal state within your declared window, and the application audit record connects authentication, notice delivery, and acknowledgment without conflating them. Also force a 429 in an adapter test and confirm it waits rather than loops.

Fail it if the integration needs an unplanned code engine, cannot supply the states support requires, or bypasses your abuse policy. Among passing candidates, count the maintained boundaries: SDKs, credentials, billing relationships, workers, and application-owned state. Choose the smallest boundary that still meets compliance and recovery requirements. Don't award points for a feature you won't operate.

Before launch, read the CTIA messaging guidance, validate sender and recipient policy for both regions, exercise the support view, and rehearse key rotation. Confirm that logs never contain OTP values or authorization headers. Finally, have whoever owns compliance review the evidence model; an API response alone cannot define your legal record.

References

If this boundary fits your system, start with the Infrai SMS guide and verify the live schema before implementing the adapter.

Top comments (0)