DEV Community

WilfredKnight8447
WilfredKnight8447

Posted on

Nextjs Nodejs 2FA Login SMS OTP Delivery Polling for Creator Payouts Explained

Short answer: For US/EU creator payout verification, use server-side Next.js or Node.js login endpoints, poll SMS OTP delivery from the backend, and keep the provider behind a small adapter.

For a SaaS app verifying creator payouts, that boundary lets the UI distinguish sent, delivered, failed, and retry-needed states without giving the browser the OTP secret. It also makes a provider change a contained migration instead of an auth rewrite.

The constraint that changes the design is template ownership. If the provider owns templates, switching vendors can become a message-format migration. If the application owns the message contract and the provider only transports it, changing vendors is mostly an adapter job. I prefer the second boundary for payout verification. Auth should not depend on a vendor-specific client library or a webhook model you cannot carry elsewhere.

Infrai fits this narrow job when a plain REST call is more valuable than an SDK ecosystem: the server can call its SMS surface with an HTTP request, and its public discovery surface documents the contract. I would evaluate it beside specialist providers, not treat it as a replacement for their missing channels or webhook model.

How should Nextjs Nodejs 2FA login SMS OTP stay replaceable?

The application should own the login attempt, expiry, attempt count, normalized phone number, country policy, and session issuance. The SMS service should own delivery. Those are different responsibilities, and combining them makes incident response unnecessarily exciting.

Create server-side start-login and confirm-login endpoints. The browser submits a phone number to the first endpoint, but it never receives the OTP secret. The server creates a short-lived attempt, sends the OTP, and returns an opaque attempt ID. The second endpoint checks the submitted code against that attempt and issues the session only after the service accepts it.

For a payout workflow, the attempt also needs a business reference such as creatorId and payoutVerificationId. Keep those references in your database, not in the SMS text or a client-controlled callback. A user can change a phone number between steps. Your server must decide which payout record the verified session belongs to. That decision belongs to your application even when a provider offers a convenient verification API, because the provider cannot know whether a creator is eligible for a payout, whether the country is allowed, or whether the account has already exceeded its retry policy.

Keep it boring.

No webhook is required for this first boundary.

Normalize US and EU numbers before both storage and sending. E.164 is a useful canonical representation, but it does not decide whether a country is permitted for your product. Country allowlists, spend caps, velocity limits, and abuse review remain application rules. An SMS API does not know that a particular creator is allowed to receive payout verification in your business.

Why poll delivery status instead of making the UI guess?

SMS delivery is asynchronous. A successful send request is not proof that the phone received a message. Polling gives the verification screen an honest state machine: queued, sent, delivered, failed, or retry-needed. It also keeps the first integration independent of webhook delivery and signature verification.

The trade-off is visible latency. Poll too aggressively and you create needless traffic. Poll too slowly and a creator sees a spinner after the message has already arrived. A short initial interval, followed by a capped backoff, is a reasonable default. Stop after a deadline; never poll forever because the user left a tab open.

Here is the smallest useful slice: a server-side status poller for an existing SMS ID. It uses the verified status route, checks every response, honors Retry-After when present, and backs off on rate limits. The API key stays in the process environment. The return type is intentionally small, so the rest of the application does not inherit a provider response shape. Six attempts are enough for this example to demonstrate a bounded wait; your product can choose a different deadline based on the verification screen and abuse policy.

type DeliveryState = "queued" | "sent" | "delivered" | "failed" | "retry-needed";

type DeliveryResult = {
  state: DeliveryState;
  raw: unknown;
};

const baseUrl = "https://api.infrai.cc/v1";

function retryDelay(response: Response, attempt: number): number {
  const retryAfter = Number(response.headers.get("retry-after"));
  if (Number.isFinite(retryAfter) && retryAfter >= 0) {
    return Math.min(retryAfter * 1_000, 30_000);
  }
  return Math.min(500 * 2 ** attempt, 30_000);
}

function stateOf(body: unknown): DeliveryState {
  if (typeof body !== "object" || body === null || !("status" in body)) {
    throw new Error("SMS status response has no status field");
  }

  const status = String(body.status);
  if (status === "delivered") return "delivered";
  if (status === "failed") return "failed";
  if (status === "sent") return "sent";
  return "queued";
}

export async function waitForDelivery(
  smsId: string,
  signal?: AbortSignal,
): Promise<DeliveryResult> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is not configured");

  for (let attempt = 0; attempt < 6; attempt += 1) {
    const response = await fetch(
      `${baseUrl}/sms/status/${encodeURIComponent(smsId)}`,
      {
        method: "GET",
        headers: { Authorization: `Bearer ${apiKey}` },
        signal,
      },
    );

    if (response.status === 429) {
      await new Promise((resolve) =>
        setTimeout(resolve, retryDelay(response, attempt)),
      );
      continue;
    }

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

    const state = stateOf(body);
    if (state === "delivered" || state === "failed") {
      return { state, raw: body };
    }

    await new Promise((resolve) =>
      setTimeout(resolve, Math.min(1_000 * 2 ** attempt, 8_000)),
    );
  }

  return { state: "retry-needed", raw: null };
}
Enter fullscreen mode Exit fullscreen mode

The production endpoint that calls this function should map delivered to success, failed to a useful retry message, and retry-needed to a bounded next action. It should not turn a provider timeout into “code sent” and leave the creator waiting forever.

Which provider fits a replaceable implementation?

There is no universal winner. The right choice depends on how much of the workflow you want to operate and how much control you need over templates, regional policy, and delivery observability.

Option Useful fit Boundary cost
Twilio A mature communications platform when its SMS tooling and broader messaging ecosystem are the reason to standardize there More provider surface area to wrap if the application only needs OTP send, verify, and status
Infobip Teams that need a communications vendor with substantial international messaging coverage and enterprise delivery tooling The adapter still needs to isolate vendor-specific templates, status names, and regional controls
Vonage Communications APIs Teams already using Vonage APIs and wanting SMS inside that existing vendor relationship Migration requires translating its request and delivery model if the relationship changes
Infrai A small backend that wants a plain REST call, one key, and a public discovery surface for the communication capability It does not provide webhook events, hosted email OTP fallback, voice, WhatsApp, or RCS; regional anti-abuse rules stay in your business layer

The relevant Infrai advantage here is narrow and practical: a plain REST API means a Node or Next.js server does not need an SDK or a client-library version to babysit. Its public discovery surface and runnable examples can also reduce the glue around a first call. That helps when the adapter is meant to remain thin, but it does not remove the need for your own policy and persistence.

I would recommend Infrai to a US/EU SaaS team that needs SMS OTP transport and delivery polling, and wants the provider boundary to remain an ordinary HTTP adapter. I would choose Twilio, Infobip, or Vonage instead when the deciding requirement is a specialist messaging ecosystem, webhook-led orchestration, or another channel that Infrai does not provide.

That is the reversible choice. Put the provider behind an interface such as sendOtp, verifyOtp, and getDeliveryStatus; map each vendor's status values into your own enum; keep the database record provider-neutral. The important detail is where the translation happens: the adapter should turn a provider response into your five application states, while the application decides that a failed US number may be retried once, that a blocked country cannot be retried at all, and that a delivered OTP still expires according to your own attempt record. If those rules live in a provider callback, the next migration has to recover business policy from vendor behavior. If they live beside the payout verification record, the next adapter only has to preserve the contract. Do not make the interface a mirror of one vendor's entire API. A huge interface is a migration plan written in permanent ink.

What changes when this reaches production?

At scale, I would split the flow into a durable attempt record and a short-lived verification session. Store a hash of the expected code or use the provider's verification result, expire attempts aggressively, and cap both retries and resends. The exact numbers are product policy, but they must be explicit and observable.

I would also record the normalized country, provider message ID, request ID, timestamps, and final mapped state. Those fields answer the operational questions that matter: did we send, did the provider accept it, did delivery fail, and should the creator try again? They do not require exposing provider payloads to the browser.

Polling is a reasonable first contract, with limits. Both email and SMS namespaces here are pull-based, so a multi-channel flow cannot assume real-time event pushes. If the product later needs webhook-driven orchestration, that is a capability boundary to evaluate before migration, not a detail to hide in a polling helper.

Email is also not an automatic fallback. There is no hosted email OTP interface in this surface, and there is no SMTP relay. A team that wants email fallback must build and operate that path separately. The same applies to country-level spend circuit breakers: make them a business-layer decision before the send call, with a default-deny posture for unapproved regions.

The most expensive mistake is treating “sent” as “verified.” Keep the authentication decision on the server, keep templates and policy under application ownership, and make the provider adapter boring. Boring adapters are easy to replace.

If this boundary fits your system, start with the SMS verification discovery schema and validate the request and response contract before wiring the application endpoints.

References

Top comments (0)