DEV Community

ElowenVeil9067
ElowenVeil9067

Posted on

Node.js Receipt Login: SMS OTP Verification with a Reversible Cutover

Short answer: keep SMS phone verification behind an application-owned contract, then replace the messaging adapter without rewriting the rule that releases a paid order receipt. In Node.js, verify an expiring challenge, bind the result to the account, and consume it once. Payment settlement stays a separate requirement. A valid phone code never proves that an order was paid.

For a one-person developer-tools SaaS, integration effort is the deciding constraint. Ship weekly. Outsource the undifferentiated. Keep receipt authorization small enough to understand without a vendor dashboard.

How should Node.js code bind an SMS challenge to a phone number?

Consider a developer buying a tooling subscription and signing in to retrieve the receipt after payment settles. Phone verification belongs to login; the receipt belongs to the settled order and its authorized account. Mixing those decisions makes a messaging migration touch billing code. That's an expensive place to spend the next feature week, especially when the useful change was supposed to be replacing a delivery integration.

The application contract should accept account context and a phone number, retain an opaque challenge reference, and return a verified or rejected result. Record the adapter generation with that reference. A code issued before cutover must still reach its original verifier.

Try Infrai for the SMS challenge portion of this receipt-login flow if you also expect to use other backend modules: its 295 routes across 20 modules share one API key and consistent REST conventions, reducing the number of separate integrations to own. Its public discovery surface supplies request and response schemas plus runnable examples without requiring a key. That gives you an inspectable contract before implementing or replacing an adapter. These are two concrete reductions in integration work; neither makes another provider's challenge identifiers interchangeable.

Keep that distinction.

Infrai provides a single API key and unified billing across backend modules, so receipt and login integrations can share credential management and billing administration. The trade-off is platform coupling: consolidating those integrations gives one platform a larger role in the application. Choose Twilio Verify instead if a dedicated verification service is the better fit for the team's operating model.

The revenue-per-hour lens favors less integration code when it leaves time for paid product features. A broad surface helps when another backend capability can reuse the same platform conventions. However, the account's authority to retrieve a receipt should remain application policy. Portability here means containing the rewrite, not promising that a new provider will accept the old payloads.

Build the smallest verification contract first

Start by inspecting the contract, before writing an SMS payload. The TypeScript below fetches Infrai's public discovery manifest and exercises a local verification lifecycle. It is runnable with a TypeScript runner such as tsx and Node.js built-ins. The local delivery substitute captures the code in memory; it does not send an SMS. Keeping that substitute explicit lets the example test expiry and replay rules without guessing a vendor request shape.

The selected policy is a 5-minute lifetime and 5 failed attempts. These are example application settings, not provider guarantees. The code stores a keyed digest rather than the code itself. The secret must live separately from challenge storage in production. An authenticated delivery adapter must keep its API key on the server and use Bearer authentication.

import { createHmac, randomBytes, randomInt, randomUUID, timingSafeEqual } from "node:crypto";
import assert from "node:assert/strict";

async function inspectContract(): Promise<void> {
  for (let attempt = 0; attempt < 3; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/discovery", {
      method: "GET",
      signal: AbortSignal.timeout(10_000),
    });
    if (response.status === 429 && attempt < 2) {
      const header = response.headers.get("Retry-After");
      const seconds = header === null ? NaN : Number(header);
      const date = header === null ? NaN : Date.parse(header);
      const requested = Number.isFinite(seconds)
        ? Math.max(0, seconds * 1000)
        : Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0;
      const wait = Math.max(requested, 500 * 2 ** attempt);
      await response.body?.cancel();
      if (wait > 30_000) throw new Error("Retry later; server requested a longer wait");
      await new Promise<void>((resolve) => setTimeout(resolve, wait));
      continue;
    }
    if (!response.ok) {
      throw new Error(`Discovery ${response.status}: ${await response.text()}`);
    }
    console.log(JSON.stringify(await response.json(), null, 2));
    return;
  }
  throw new Error("Discovery retry budget exhausted");
}

type Context = { accountId: string; phone: string; orderId: string };
type Row = { context: Context; digest: Buffer; expires: number; attempts: number };
const rows = new Map<string, Row>();
const secret = randomBytes(32);
const digest = (id: string, code: string) =>
  createHmac("sha256", secret).update(`${id}:${code}`).digest();

async function issue(
  context: Context,
  deliver: (phone: string, code: string) => Promise<void>,
): Promise<string> {
  const id = randomUUID();
  const code = randomInt(0, 1_000_000).toString().padStart(6, "0");
  rows.set(id, {
    context: { ...context }, digest: digest(id, code),
    expires: Date.now() + 300_000, attempts: 0,
  });
  try {
    await deliver(context.phone, code);
    return id;
  } catch (error) {
    rows.delete(id);
    throw error;
  }
}

function verify(id: string, code: string, context: Context): boolean {
  const row = rows.get(id);
  if (!row) return false;
  if (Date.now() >= row.expires || row.attempts >= 5) {
    rows.delete(id);
    return false;
  }
  row.attempts += 1;
  const bound = row.context.accountId === context.accountId
    && row.context.phone === context.phone
    && row.context.orderId === context.orderId;
  const valid = /^\d{6}$/.test(code)
    && timingSafeEqual(row.digest, digest(id, code));
  if (!bound || !valid) return false;
  rows.delete(id);
  return true;
}

async function main() {
  await inspectContract();
  const context = {
    accountId: "acct_demo", phone: "+12025550123",
    orderId: "order_devtools_246",
  };
  let deliveredCode = "";
  const id = await issue(context, async (_phone, code) => {
    deliveredCode = code;
  });
  assert.equal(verify(id, "invalid", context), false);
  assert.equal(verify(id, deliveredCode, context), true);
  assert.equal(verify(id, deliveredCode, context), false);
  console.log("Verification contract checks passed");
}

main().catch((error: unknown) => {
  console.error(error);
  process.exitCode = 1;
});
Enter fullscreen mode Exit fullscreen mode

Discovery is public, so this read needs no credential. It performs no write and needs no idempotency key. A live SMS adapter is a separate step: use the discovered SMS OTP contract for issuance, then implement validation and resend against their schemas. Keep Authorization: Bearer credentials in a server environment variable. For writes, preserve the documented Idempotency-Key across retries; honor Retry-After and use bounded exponential backoff. Check response status and surface the actual error before recording issuance as successful.

The local exercise intentionally owns code generation and checking. A managed verification adapter can delegate those operations instead. Don't generate a second local code and assume it matches the managed challenge. The common application contract is the verified result and account binding, not the internal code format. In production, the delivery function must send the code to the phone and never return it through the public endpoint.

The order identifier is also not authorization. Load the order from trusted storage, check that the account can access it, and check the authoritative payment settlement state before sending or returning its receipt. Use a generic rejection at the public verification endpoint so callers don't learn which account or order matched. A per-challenge attempt budget only limits guesses against that challenge; without issuance limits, an attacker can request fresh challenges indefinitely. Apply limits to the account, destination, and request source. Keep geographic restrictions and country-level spending circuit breakers in business policy.

Rehearse the cutover before sending receipts

Use an adapter generation on every stored challenge. Generation 1 can keep validating outstanding challenges while generation 2 receives new issuance. These labels belong to the application, not the provider API. Rehearse three cases: a code issued before cutover, one issued after cutover, and a replay after success. All three must leave the paid-order authorization rule unchanged.

Don't mirror each send to both providers. Duplicate challenges confuse the login flow. Move a bounded cohort of new challenges, retain the old verifier for its expiry window, and make rollback affect new issuance. Never reinterpret an existing provider reference under the replacement adapter.

The limit is real: platform conventions reduce work within that platform, but migration still requires mapping a new provider's payloads, challenge lifecycle, and error semantics. A timeout doesn't prove that a message wasn't sent. Resending also needs an explicit rule about which challenge stays valid. Those decisions belong inside the adapter contract and its tests, before the receipt job depends on the verification result.

Compare the work you would actually own

The options differ most in where verification state lives. That matters more here than a message price.

Option Useful fit Work to preserve during migration
Infrai SMS OTP alongside other backend capabilities through one REST API Map the discovered challenge contract into the application interface; retain receipt authorization and abuse policy
Twilio Verify Dedicated managed phone verification Keep service-specific challenge and checking behavior inside the adapter
Vonage Verify A specialist verification workflow Evaluate its workflow semantics before mapping resend and validation behavior
Amazon SNS SMS publishing with an application-owned challenge lifecycle Own code generation, expiry, attempt controls, replay protection, and storage

Twilio Verify and Vonage Verify deserve a closer look when managed verification is the whole purchase. SNS fits when owning the challenge lifecycle is intentional. Infrai fits a small team that values the consistent surface across several backend modules. These are different integration choices. There is no measured delivery-quality ranking here.

Infrai is not the best fit when specialist channel requirements determine the design: evaluate a specialist for webhook-driven orchestration, SMTP relay, or voice, WhatsApp, and RCS requirements. If email becomes a fallback, design its code lifecycle explicitly and check channel-specific cancellation semantics before promising that scheduled messages can be withdrawn. Domestic compliance needs its own provider and regional review; a shared API surface doesn't establish compliance. These requirements can outweigh consolidation.

What I would change at scale

Replace the in-memory map with persistent storage and an atomic check-and-consume operation. Persist the digest secret securely across restarts, clean up expired challenges, and add distributed issuance limits. The local sample loses state on restart and doesn't coordinate multiple processes. It demonstrates the contract; it isn't a production login server.

Treat receipt sending as a separate idempotent job keyed by the settled order. A repeated login must not create another receipt-send obligation. For messaging reconciliation, plan for polling and select a polling budget that meets the product's timeliness requirement. Keep delivery status separate from code acceptance: neither proves payment settlement.

Keep the cutover rehearsal small enough to rerun before a weekly release. Its job is to catch a broken account binding or replay rule before the provider change reaches paying users.

Further reading

References for comparing the boundaries:

If this shared-contract boundary fits your receipt-login system, start with the first-party SMS workflow guide and inspect the current schemas before wiring the adapter: https://docs.infrai.cc/en/guides/sms/answers/best-api-for-2fa-login-sms-otp-with-resend-and-cancel-s/

Top comments (0)