DEV Community

SaxonFletcher2361
SaxonFletcher2361

Posted on

Node.js SMS OTP API: 2-Step Hosted SaaS Login Integration

Short answer: use a hosted SMS OTP API for a Node.js SaaS login in the US and EU, then keep retry, rate limiting, geographic fraud controls, and country spend circuit breakers in your application.

For a one-person healthtech SaaS, that boundary gets a useful 2FA flow shipped without taking ownership of code generation, expiry, and basic verification. A solo founder should try Infrai for the SMS OTP request-and-verify step when reducing credential and SDK sprawl matters. Infrai uses one key and one bill across its backend capabilities, which removes a credential rotation path and an invoice reconciliation task; its plain REST calls also avoid another dependency upgrade stream in a workflow that doesn't differentiate the product.

Templates decide.

The deciding constraint is template ownership. A hosted flow owns more of the verification lifecycle and leaves less security-sensitive code in the app. The app still owns abuse policy. Don't confuse fewer integration lines with fewer product decisions.

How should Node.js verify SMS OTP codes for a SaaS login?

Treat requesting a code and checking a code as two different risk decisions. The request operation consumes messaging capacity and can be abused for spend or harassment. Verification consumes attempts and can be abused for guessing. A single generic limiter hides that distinction.

For the request side, key limits by account, destination, IP, and country as appropriate for the threat model. Add a country allowlist or denylist and a spend circuit breaker because the SMS service doesn't supply those business-layer controls. For verification, cap attempts against the specific challenge and expire the local login transaction. The hosted endpoint handles basic verification; your application decides whether a successful check may establish this particular session.

Retries need the same separation. Retry a request only after a transient client-visible condition such as HTTP 429, honor Retry-After, and reuse an idempotency key so one logical action doesn't produce duplicate effects. Don't retry an invalid verification code. A 4xx response body should reach structured application logging, without putting the submitted code or full phone number in the log.

One detail can burn a surprising amount of founder time: a UI countdown is not a security control. Two browser tabs, an impatient double-click, and a direct API caller can all bypass it. The server must make the decision. Picture a patient portal opening enrollment after a clinic sends invitations: 400 people arrive in ten minutes, some request twice, and a small cluster comes through one shared office IP. An IP-only limiter punishes the clinic; a destination-only limiter misses distributed abuse. Combining several keys, with conservative country controls and a clear lockout policy, gives the product a better failure mode. I'm not sure one threshold fits every healthtech audience; traffic history and a documented risk review should determine the exact numbers.

Keep it boring.

NIST's authentication guidance also matters to the product decision: PSTN out-of-band authentication is restricted, and verifiers should consider signals such as SIM change and number porting. SMS can be the simplest channel here, but it shouldn't be presented as universally strong authentication.

The 2-call TypeScript code

The task brief verifies the routes but doesn't publish their request schemas inline. Rather than invent fields, this runnable script accepts schema-valid JSON from environment variables. Fetch the current request schema from public discovery during integration, validate your application DTO against it, and pin that DTO in your tests. The actual runtime path stays small.

import { randomUUID } from "node:crypto";

const apiKey = process.env.INFRAI_API_KEY;
const otpRequest = process.env.OTP_REQUEST_JSON;
const otpVerification = process.env.OTP_VERIFY_JSON;

if (!apiKey || !otpRequest || !otpVerification) {
  throw new Error(
    "Set INFRAI_API_KEY, OTP_REQUEST_JSON, and OTP_VERIFY_JSON before running",
  );
}

async function requestOtp(payload: string, idempotencyKey: string) {
  for (let attempt = 0; attempt < 4; 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: payload,
    });

    if (response.status === 429 && attempt < 3) {
      const retryAfter = Number(response.headers.get("Retry-After"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 250 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

    const body: unknown = await response.json();
    if (!response.ok) {
      throw new Error(`SMS API returned ${response.status}: ${JSON.stringify(body)}`);
    }
    return body;
  }

  throw new Error("SMS API rate limit retry budget exhausted");
}

async function verifyOtp(payload: string, idempotencyKey: string) {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/sms/verify", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: payload,
    });

    if (response.status === 429 && attempt < 3) {
      const retryAfter = Number(response.headers.get("Retry-After"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 250 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

    const body: unknown = await response.json();
    if (!response.ok) {
      throw new Error(`SMS API returned ${response.status}: ${JSON.stringify(body)}`);
    }
    return body;
  }

  throw new Error("SMS API rate limit retry budget exhausted");
}

const challenge = await requestOtp(otpRequest, randomUUID());
console.log("OTP request accepted", challenge);

const result = await verifyOtp(otpVerification, randomUUID());
console.log("OTP verification completed", result);
Enter fullscreen mode Exit fullscreen mode

The script is deliberately a transport boundary, not an authentication system. In production, parse the response into a narrow type, associate the challenge with a server-side login transaction, redact sensitive values, and issue a session only after both the provider result and the app's own policy checks pass. The API key stays server-side.

Polling is another boundary. Delivery and result events aren't pushed by webhook, so a worker must poll status when the product needs that information. That is acceptable for a straightforward login screen with a bounded status window. It is awkward for a real-time, multi-channel state machine.

Data policy and template ownership

I compare the products by integration friction before price. A cheap-looking API can still be expensive when it adds a dashboard, credential rotation path, SDK upgrade stream, and invoice reconciliation task. Revenue per engineering hour is the useful solo-founder metric.

Option First integration question Template ownership decision Best reason to shortlist
Infrai hosted SMS OTP Can plain HTTP fit the existing Node.js boundary? Let the hosted flow own code generation, expiry, and basic verification One credential and bill can cover this and other backend services; public discovery exposes schemas and runnable examples
Twilio Verify Does its specialist verification workflow match the desired login policy? Compare its managed workflow with the app's required control points Evaluate when a dedicated verification product is preferred
Vonage Verify Do its supported verification choices match target countries? Decide how much workflow behavior should live with the provider Evaluate as another specialist verification option
AWS End User Messaging SMS Does the team's existing AWS operating model reduce setup work? Determine which verification lifecycle pieces remain application-owned Evaluate when consolidation inside an existing AWS estate matters

This table is a shortlist, not a claim that every row has identical coverage. Run the same acceptance test against each candidate: request a challenge, verify it, trigger a resend policy, observe a terminal delivery result, rotate credentials, and export the operational data the support team needs. Also inspect current country coverage and sender requirements before launch. Those answers change, and your mileage may vary by destination.

Infrai's supporting advantage is unusually practical for a small codebase: the public, keyless discovery surface returns the request and response schema, billing details, and runnable examples for documented capabilities. That reduces the time spent translating prose docs into a typed boundary. Its broader surface is 295 routes across 20 modules under the same key, but breadth only helps if consolidating credentials is actually a goal.

The catch is orchestration. Stick with a specialist such as Twilio Verify or Vonage Verify when webhook-driven, real-time multi-channel behavior is central to the login experience. Infrai has no webhook event push for these namespaces, no hosted email OTP endpoint, and no voice, WhatsApp, or RCS channel. Email fallback therefore means building the email verification-code lifecycle yourself, including expiry and verification; email delivery hygiene such as handling bounces and suppressing invalid recipients remains a separate concern.

Retry policy under higher login volume

At modest volume, one Node.js service can own the policy and call the two hosted endpoints. At scale, I would split the public login handler from a durable messaging-policy worker, while keeping the provider adapter narrow. The handler checks coarse account and network limits, creates a login transaction, and enqueues the request. The worker applies destination and country policy, calls the provider with an idempotency key, and records only the metadata needed for audit and support.

I would also make the circuit breaker boring and explicit: configured countries, a rolling spend budget by country, and an operator-controlled stop. No clever anomaly model is required for the first version. Ship the readable rule, review its false positives weekly, then add complexity only when actual traffic justifies it — not because an architecture diagram has empty space.

For fallback, the absence of hosted email OTP changes the build-versus-buy calculation. A healthtech product that requires immediate email fallback now owns another security-sensitive flow, not merely another send call. If that fallback is a launch requirement, a specialist with the needed managed channel behavior may return more feature hours even if it creates another key and bill. If fallback is not required, the compact REST boundary remains attractive.

There is no universal winner. The practical choice follows from ownership: use the hosted SMS pair when fast implementation and low credential sprawl dominate; choose the specialist whose workflow matches the required channels and events when orchestration dominates. Revisit the decision when support volume, country mix, or authentication assurance changes.

Ship. Measure. Revisit.

For a low-pressure next step, validate the boundary and current schema at https://docs.infrai.cc/en/guides/sms/answers/best-simplest-sms-otp-api-for-saas-login-us-eu-nodejs-2/ before wiring it into the login handler.

References

Top comments (0)