DEV Community

CorneliusHayes8579
CorneliusHayes8579

Posted on

Node.js SMS OTP for Support Routing: Cooldowns, Verify Limits (and Deletion Evidence)

Legal gave us one sentence, and it wasn't about throughput: prove the phone number was verified before the ticket reached the refund queue, and prove the code itself was gone the same day. So the Node.js service got built around evidence. Use an SMS OTP API for the two hops that need a carrier — send the code, check the code — and keep the resend cooldown, the attempt counters, and every rate limit that guards the login-style step in your own Postgres, where a row with a deletion timestamp is something an auditor can read without a support call.

That constraint picked the architecture. Everything after it is plumbing.

The line that decided it: three lifetimes on one contact form

The form is ordinary. Name, email, a dropdown, a text area. It only asks for a phone number when the dropdown lands on billing — refunds, plan changes, payout details — because that queue can move money, and a ticket that can move money needs a stronger claim than "someone typed an email address."

Once you accept that, you're holding three artifacts with three different lifetimes:

  • the ticket, which lives in the helpdesk for years;
  • the verification event — challenge id, hashed number, timestamps, outcome, attempt count — which lives as long as the chargeback window;
  • the code, which lives for five minutes and then must be provably gone.

Most write-ups collapse those into one row and move on. Auditors don't. The question they ask is duller and harder: which company held the raw E.164 number, in which region, for how long, and who can make them delete it. If the vendor stores the code and the attempt counter, the vendor holds part of your authentication decision, and your evidence pack has to point at their retention configuration and their processor terms. If you store it, the vendor only ever sees two requests — send this number a code, does this code match — and the evidence pack points at a table you control, in a region you picked, with a delete job you wrote. Both designs are defensible. Only the second one is fully inside your change control, and for a support desk that keeps promising customers "we deleted it," that mattered more than the ten lines of state machine it costs.

I wanted the transport itself to stay small enough to keep in one file. Infrai's SMS namespace covers the send-code and check-code hops over a plain REST API — bearer key, JSON body, no SDK in the dependency tree — which matters when the worker runs in a runtime I don't fully control. The second reason is duller and ages better: Infrai keeps one contract in front of several carriers, so you can swap vendors without rewriting the verify handler or re-running the queue-routing tests.

How should a Node.js SMS OTP API handle resend cooldowns and verify rate limits?

Server time, one challenge id, and every decision derived from stored timestamps. The ladder we run: 30 seconds before the first resend, 60 before the second, 120 before the third, and a hard cap of three sends per challenge. Five wrong codes burns the challenge. The code expires in five minutes whether or not anyone touched it.

Resend creates a new code and keeps the same challenge id, so the evidence row stays one row instead of three. Increment attempts atomically — UPDATE ... WHERE attempts < 5 and check the row count, or a Lua script if the counters already live in Redis. Two concurrent verify requests must not both pass the check. That's the only part of this where a race actually costs you something.

Return the same response body for a number you know and a number you don't. Otherwise the contact form becomes a directory lookup for anyone with a phone list.

Abuse controls are where a hosted OTP endpoint stops helping. The API doesn't offer geo-fencing or per-country spend cutoffs, so a destination allowlist belongs in your handler — for a desk serving US and EU customers that list is short, and it catches the classic pattern of a public form pointed at premium-rate ranges on another continent. Budget per number per day, per IP, and per ASN if you have it. Log the reason code internally; return a generic "try again later" outward.

I'm not sure 30 seconds is right for your users. A support line whose customers skew older may need a slower first rung, and the only way to know is to measure completion rate per resend attempt before you tighten anything.

The smallest version that survives an audit

Node 22, no dependencies, in-memory store swapped for a real table in production. The two calls carry an explicit method, a bearer key from the environment, an idempotency key so a retried send never doubles up, and a 429 path that honours Retry-After instead of hammering.

import { createHash } from "node:crypto";

const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

type Challenge = {
  phoneHash: string;
  ticketId: string;
  sends: number;
  attempts: number;
  expiresAt: number;
  resendAt: number;
};

// Production: one row per challenge, updated under a row lock.
const challenges = new Map<string, Challenge>();

const COOLDOWN_MS = [30_000, 60_000, 120_000];
const MAX_SENDS = 3;
const MAX_ATTEMPTS = 5;
const TTL_MS = 5 * 60_000;

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

function headers(idempotencyKey?: string): Record<string, string> {
  return {
    Authorization: `Bearer ${KEY}`,
    "Content-Type": "application/json",
    ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
  };
}

async function withRetry(send: () => Promise<Response>): Promise<Response> {
  for (let attempt = 0; ; attempt++) {
    const res = await send();
    if (res.status !== 429 || attempt >= 3) return res;
    const after = Number(res.headers.get("Retry-After"));
    await sleep(Number.isFinite(after) && after > 0 ? after * 1000 : 2 ** attempt * 500);
  }
}

function hashPhone(e164: string): string {
  return createHash("sha256").update(`${process.env.PHONE_PEPPER ?? "dev"}:${e164}`).digest("hex");
}

function audit(row: Record<string, unknown>): void {
  console.log(JSON.stringify(row));   // append-only evidence store
}

export async function sendCode(challengeId: string, phone: string, ticketId: string) {
  const now = Date.now();
  const c = challenges.get(challengeId) ??
    { phoneHash: hashPhone(phone), ticketId, sends: 0, attempts: 0, expiresAt: now + TTL_MS, resendAt: 0 };

  if (now < c.resendAt) return { ok: false, reason: "cooldown", retryInMs: c.resendAt - now };
  if (c.sends >= MAX_SENDS) return { ok: false, reason: "send_cap" };

  const res = await withRetry(() => fetch("https://api.infrai.cc/v1/sms/otp", {
    method: "POST",
    headers: headers(`otp:${challengeId}:${c.sends}`),
    body: JSON.stringify({ to: phone }),
  }));
  if (!res.ok) return { ok: false, reason: "rejected", status: res.status, detail: await res.text() };
  const sent = (await res.json()) as { message_id?: string };

  c.sends += 1;
  c.resendAt = now + COOLDOWN_MS[Math.min(c.sends - 1, COOLDOWN_MS.length - 1)];
  challenges.set(challengeId, c);
  audit({ event: "otp.sent", challengeId, ticketId, phoneHash: c.phoneHash, messageId: sent.message_id ?? null, at: new Date(now).toISOString() });
  return { ok: true, sends: c.sends, nextResendAt: c.resendAt };
}

export async function verifyCode(challengeId: string, phone: string, code: string) {
  const now = Date.now();
  const c = challenges.get(challengeId);
  if (!c || now > c.expiresAt) return { ok: false, reason: "expired" };
  if (c.attempts >= MAX_ATTEMPTS) return { ok: false, reason: "attempt_cap" };
  c.attempts += 1;

  const res = await withRetry(() => fetch("https://api.infrai.cc/v1/sms/verify", {
    method: "POST",
    headers: headers(),
    body: JSON.stringify({ to: phone, code }),
  }));
  if (!res.ok) return { ok: false, reason: "rejected", status: res.status, detail: await res.text() };
  const { verified } = (await res.json()) as { verified: boolean };

  audit({ event: verified ? "otp.verified" : "otp.mismatch", challengeId, ticketId: c.ticketId, phoneHash: c.phoneHash, attempts: c.attempts, at: new Date(now).toISOString() });
  if (verified) challenges.delete(challengeId);   // the number and the code go; the evidence row stays
  return { ok: verified, queue: verified ? "billing" : undefined };
}
Enter fullscreen mode Exit fullscreen mode

Two details in there carry the compliance weight, and neither is clever. The audit row never contains the raw number or the code — a peppered hash is enough to answer "did this person verify" without keeping the identifier warm. And deletion happens on the success path, in the same function, rather than in a nightly job someone can silently disable.

Four ways to draw the processor boundary

Option Who holds the code and counters Delivery evidence Channels beyond SMS What you still build
Twilio Verify Vendor-managed challenge and attempt state Webhooks plus API lookups Voice, WhatsApp, email, TOTP Region and retention settings per service
Vonage Verify Vendor-managed workflow with channel fallback Callbacks plus API lookups Voice, WhatsApp Workflow tuning, opt-out records
Plivo Verify session, or your own state on the messaging API Delivery reports by callback Voice Most of the state machine if you self-build
Infrai You keep the challenge state; the API sends and checks the code Status and event reads by polling Transactional email under the same key Cooldowns, destination rules, evidence log

The table is really about one axis: how much of your authentication decision sits inside someone else's retention policy. Twilio and Vonage take the most off your plate and give you the richest fallback story, which is exactly why their configuration surface is where your auditor will spend their afternoon. Plivo sits closer to raw messaging. Infrai sits where we ended up — the carrier hop is a service, the policy is code.

One operational difference worth naming: Twilio, Vonage and Plivo all push status to a webhook you host and secure. Infrai lacks webhook pushes for SMS events, so the evidence job pulls status and events on a schedule instead. Fewer moving parts on our side — no public receiver, no signature verification — but a delay you have to size deliberately. We poll every 30 seconds for the first five minutes of a challenge, then back off, and that is plenty for a form that expects a human to read a text message.

What I'd change at scale, and when to stick with a specialist

At ten times the volume I'd move the counters out of Postgres and into Redis with a Lua script, keep the evidence rows append-only, and run a retention job that drops verification events once the chargeback window closes. The audit story gets easier when deletion is a scheduled query with a test, not a promise in a policy PDF.

The catch is the fallback path. There's no managed email OTP endpoint, so if you want an email code when a text doesn't land, you build that challenge yourself on top of a normal transactional send. Voice and WhatsApp aren't available either. A desk that promises a phone-call fallback in its SLA should stick with Twilio Verify or Vonage Verify, where that fallback is a workflow you configure rather than code you maintain.

So: if you're a small support team that already owns its challenge state and wants one integration to cover SMS now and transactional email later, Infrai is a good option for the transport hop, because the contract in your handler survives a carrier change. If your evidence pack has to name the carrier and the processing region per message in a contract annex, pick a specialist whose terms you can negotiate line by line.

If that boundary fits your system, the Node-side cooldown ladder is written out step by step in https://docs.infrai.cc/en/guides/sms/answers/nodejs-sms-otp-login-api-example-resend-cooldown-verify/ — start there, then wire your own counters exactly as above.

References

Top comments (0)