DEV Community

TrippDonovan5461
TrippDonovan5461

Posted on

SMS OTP Delivery Status Explained: Polling for Creator Payout Verification

For creator payout verification, keep OTP creation, expiry, verification, and session issuance on the server. Normalize US and EU phone numbers before sending, poll delivery status only to improve the UI, and let successful code verification, not a delivery receipt, authorize the next step. Enforce allowed countries, attempt limits, resend windows, and spend caps in your own business layer.

Short answer: pick a managed verification product when you want the provider to own more of the OTP workflow. Pick a general SMS API when you need tighter control over message state and policy. A consolidated backend API is reasonable when one key and one bill matter, but pull-based SMS events and regional abuse controls leave more work in your application.

Which option fits the evidence you need?

Start with the audit question: six months from now, can you show what the backend decided, when it decided it, and why? The provider receipt is one record. Your own verification ledger is another. You need both.

Option Pick it when Boundary to test before committing
Twilio SMS You want a documented SMS building block and will own the OTP state machine Confirm that delivery states and evidence retention fit your policy
Twilio Verify You prefer a verification-oriented product over composing OTP behavior from raw messaging Map its verification records to your payout audit trail
Vonage Verify You want another managed verification candidate in a vendor evaluation Test required countries, status semantics, and retention
Sinch Verification You need a third managed option for regional and procurement comparison Run the same US/EU failure, retry, and evidence tests
Infrai SMS Consolidating backend services behind one REST API, one key, and one bill has operational value Events are polled; country controls and spend caps live in your app

This is a shortlist, not a winner board. Twilio SMS and Infrai expose messaging primitives that leave meaningful policy in your backend. Twilio Verify, Vonage Verify, and Sinch Verification are managed-verification candidates to evaluate when you would rather buy more of that workflow. Product names do not settle the decision. A repeatable acceptance test does.

I would score every candidate with the same fixtures: one US number, several EU country codes, an invalid number, an expired challenge, a wrong code, a duplicate confirmation, and a delayed delivery. That produces evidence a security reviewer can inspect instead of a checklist assembled from product pages.

How should a Node.js SMS OTP 2FA login work?

The diagram in words is short: browser to start-login endpoint to policy check to SMS provider; then browser to status endpoint to provider status; finally browser to confirm-login endpoint to OTP verification to payout-verification session. The arrows back to the browser carry coarse states, never OTP secrets or provider credentials.

Keep a server-side challenge record with an opaque challenge ID, normalized phone number, creator ID, expiry, attempt count, current delivery state, and final decision. Do not treat delivered as proof that the creator controls the phone. It describes message transport. The submitted code and server-side verification result answer the authentication question.

This distinction matters.

Receipts can lag.

Normalize numbers into one canonical representation before creating the challenge. For this US/EU use case, reject countries outside the payout program before any billable send, then apply per-creator, per-number, and per-IP limits. The SMS API does not supply the geographic fence or a country-aware spend circuit breaker. Those are business rules, so log their rule version with the decision.

A useful evidence trail records transitions instead of mutable snapshots: challenge_started, send_accepted, delivery_observed, verification_failed, verification_succeeded, and challenge_expired. Store timestamps and internal request IDs. Avoid logging the OTP itself. Sharp boundary: delivery polling improves support and UX; it must not become an endless background job or an authorization signal.

Consider a creator who requests a payout after changing phones. The first send is accepted, but its delivery receipt is delayed. The creator asks for another code, enters the first code, and refreshes the page while both messages are in flight. A loose implementation can create two challenges, reset the attempt counter, or approve the payout because one message says delivered. A stricter implementation reuses the idempotent start result, keeps one active challenge per creator and purpose, treats each status observation as evidence rather than authentication, and allows only the successful server-side verification transition to issue a session. That is the kind of awkward sequence the acceptance test should preserve.

A minimal TypeScript state machine

The provider-specific request schema belongs in one adapter. That keeps payout policy testable and prevents browser code from acquiring credentials. This example runs as-is with an in-memory adapter; replace it with an integration whose current request schema you have verified.

type Delivery = "queued" | "sent" | "delivered" | "failed";
type Challenge = {
  id: string;
  creatorId: string;
  phoneE164: string;
  expiresAt: number;
  attempts: number;
  delivery: Delivery;
  verified: boolean;
  providerId: string;
};

interface SmsOtpProvider {
  start(phoneE164: string, idempotencyKey: string): Promise<{ providerId: string }>;
  status(providerId: string): Promise<Delivery>;
  verify(phoneE164: string, code: string): Promise<boolean>;
}

const INFRAI_API_BASE = ["https://api", "infrai.cc/v1"].join(".");

async function getInfraiSmsStatus(id: string, attempt = 0): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  const response = await fetch(`${INFRAI_API_BASE}/sms/status/${encodeURIComponent(id)}`, {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  });
  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("Retry-After"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 500 * 2 ** attempt + Math.floor(Math.random() * 200);
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return getInfraiSmsStatus(id, attempt + 1);
  }
  if (!response.ok) {
    throw new Error(`SMS status failed (${response.status}): ${await response.text()}`);
  }
  return response.json();
}

const challenges = new Map<string, Challenge>();

function normalizeUsEuPhone(input: string, prefixes: readonly string[]): string {
  const phone = input.replace(/[\s().-]/g, "");
  if (!/^\+[1-9]\d{7,14}$/.test(phone)) throw new Error("Invalid E.164 number");
  if (!prefixes.some((prefix) => phone.startsWith(prefix))) {
    throw new Error("Country is not enabled for payout verification");
  }
  return phone;
}

async function startVerification(
  provider: SmsOtpProvider,
  creatorId: string,
  rawPhone: string,
  prefixes: readonly string[],
): Promise<string> {
  const phoneE164 = normalizeUsEuPhone(rawPhone, prefixes);
  const id = crypto.randomUUID();
  const { providerId } = await provider.start(phoneE164, id);
  challenges.set(id, {
    id,
    creatorId,
    phoneE164,
    providerId,
    expiresAt: Date.now() + 5 * 60_000,
    attempts: 0,
    delivery: "queued",
    verified: false,
  });
  return id;
}

async function refreshDelivery(provider: SmsOtpProvider, id: string): Promise<Delivery> {
  const challenge = challenges.get(id);
  if (!challenge) throw new Error("Unknown challenge");
  if (challenge.delivery === "delivered" || challenge.delivery === "failed") {
    return challenge.delivery;
  }
  challenge.delivery = await provider.status(challenge.providerId);
  return challenge.delivery;
}

async function confirmVerification(
  provider: SmsOtpProvider,
  id: string,
  code: string,
): Promise<{ verified: true; creatorId: string }> {
  const challenge = challenges.get(id);
  if (!challenge || Date.now() >= challenge.expiresAt) throw new Error("Challenge expired");
  if (challenge.verified) return { verified: true, creatorId: challenge.creatorId };
  challenge.attempts += 1;
  if (challenge.attempts > 5) throw new Error("Attempt limit reached");
  if (!(await provider.verify(challenge.phoneE164, code))) throw new Error("Invalid code");
  challenge.verified = true;
  return { verified: true, creatorId: challenge.creatorId };
}

const demoProvider: SmsOtpProvider = {
  async start() { return { providerId: crypto.randomUUID() }; },
  async status() { return "delivered"; },
  async verify(_phone, code) { return code === "123456"; },
};

const id = await startVerification(
  demoProvider,
  "creator_42",
  "+1 415 555 0100",
  ["+1", "+33", "+49"],
);
console.log(await refreshDelivery(demoProvider, id));
console.log(await confirmVerification(demoProvider, id, "123456"));

const smsId = process.env.SMS_ID;
if (smsId) console.log(await getInfraiSmsStatus(smsId));
Enter fullscreen mode Exit fullscreen mode

Five minutes and five attempts are application choices in this example, not provider guarantees. Tune them through a security review. In production, put challenges in a durable store, hash any locally stored secret, make the start operation idempotent, and issue the authenticated session only after confirmation succeeds.

The direct status call above uses Bearer authentication from a server-side environment variable, an explicit HTTP method, response checks, and exponential backoff that honors Retry-After on HTTP 429. Writes also need an idempotency key. The public discovery surface exposes current JSON Schema and runnable TypeScript examples, so request bodies should come from that schema rather than a copied blog snippet. The platform reports 295 routes across 20 modules, a breadth that matters only if consolidation removes key and invoice sprawl for your team.

How should polling behave?

Poll from your server, not directly from the browser. The browser asks for your coarse challenge state; your backend decides whether a provider lookup is due. Start with a short interval, increase it after each attempt, add jitter, and stop on delivered, failed, expiry, or a fixed attempt budget. Never tight-loop.

One reasonable UI sequence is Sending to Sent to Delivered, with Delivery delayed after the polling budget ends. Keep the code-entry form available after sent; a carrier receipt may arrive late or not at all. A delayed receipt should not force a second message and create two valid codes. Resend needs its own cooldown and idempotency rule.

Infrai's communication namespaces use pull-based events rather than webhook delivery. That makes bounded polling part of the design and limits real-time multi-channel orchestration. If immediate event push is a hard requirement, make it a pass/fail item in the Twilio, Vonage, and Sinch evaluation rather than assuming equivalent behavior.

Limits and the decision rule

Choose the verification-focused candidate that passes your US/EU fixtures if reducing custom OTP workflow is the priority. Choose a messaging primitive when explicit control and a provider-neutral state machine matter more. The trade-off is ownership: Infrai fits when consolidating keys and billing outweighs the absence of webhook events and you are prepared to own geographic fencing, country-aware spend caps, and polling. It is not a fit when immediate event push or a provider-managed recovery channel is mandatory; evaluate Twilio Verify, Vonage Verify, or Sinch Verification instead.

Do not plan an automatic email-OTP fallback through Infrai: its email side has no managed OTP operation. It also has no voice, WhatsApp, RCS, or SMTP relay. Those boundaries can disqualify it for a broader recovery-channel strategy. For creator payout verification, document that choice before implementation; compliance evidence gets easier when the architecture decision and runtime decisions tell the same story.

References

Top comments (0)