Node.js SMS OTP: the resend cooldown ladder, coded properly

A five-state challenge machine for phone login in Node 22 — cooldown timers, an attempt budget, and the 503 that will make a naive retry loop send twice.

The interesting code in a phone-login screen isn’t the send. It’s the clock behind the “Resend code” button — how long it stays greyed out, how many times it can be pressed before the challenge dies, and what happens when the gateway answers with something ambiguous halfway through. Infrai’s POST /v1/sms/otp and POST /v1/sms/verify handle the passcode itself, so everything left is timing, and timing is where the money leaks.

This walks through that clock as a state machine you can drop into a Node 22 service. No Redis required to follow along, though you’ll want one in front of a multi-instance deployment — the store interface at the bottom is deliberately swappable. Twilio Verify bundles some of these throttles into its service configuration; here they’re yours, which costs you thirty lines and buys you the ability to tune them per market.

Five states, and the transitions that cost money

StateWhat the UI showsExit
freshPhone inputA send request → cooling
coolingTimer counting down, resend disabledTimer hits zero → open
openResend enabled, code box activeResend → cooling; correct code → done
locked”Too many attempts, start over”Nothing. Discard the challenge.
doneSession issued

Two of those transitions are billed. Every arrival at cooling from a send is a message charge, and every verify attempt — right or wrong — is a per-call charge. fresh → cooling → open → cooling is what an impatient user does in about forty seconds, and it’s two messages.

The other three transitions are free.

Why the resend route isn’t the resend button

This one surprises people. POST /v1/sms/resend/{id} exists, and it takes the message_id that POST /v1/sms/send hands back — the ordinary send lifecycle, where you wrote the body and you own the text. Managed OTP is a different lifecycle: POST /v1/sms/otp returns a request_id and sent, because there’s no body of yours to replay.

So on an OTP screen, resending means calling POST /v1/sms/otp again with the same to. The gateway issues a fresh code against that number; the old one stops being useful. That’s simpler than it sounds, but it means your cooldown can’t lean on a message id — it has to key on the phone number, exactly like verify does.

The cooldown ladder

A flat 60-second cooldown is the common choice and it’s fine. Escalating is better, because the second resend is usually a genuinely slow carrier and the fourth is usually someone poking at you:

Resend #Wait before it unlocksCumulative message cost
1 (initial send)30s1 message
260s2 messages
3120s3 messages
4+refused — challenge goes lockedcapped at 3

Capping at three resends per challenge and five verify attempts per challenge is the whole abuse story for a single number. Verified 2026-07-26: a message costs about $0.0075 and a verify call $0.005, so a fully abused challenge tops out near $0.05 — read the current numbers rather than trusting mine, since these rates drift downward and campaigns run:

curl -s https://api.infrai.cc/v1/discovery \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '[.capabilities[] | select(.id | startswith("sms.")) | {id, price: .billing.price_usd, unit: .billing.unit, billable: .billing.is_billable}]'

New accounts get $2 free credit, which covers a couple of hundred sends — plenty to build against, not a shield.

The requests themselves

curl -X POST https://api.infrai.cc/v1/sms/otp \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{ "to": "+447700900123", "template": "{code} is your login code. Do not share it." }'
{
  "ok": true,
  "data": { "request_id": "req_4a81c0e5f7", "sent": true },
  "metadata": { "vendor": "tencent_sms", "cost_usd": 0.007475, "latency_ms": 612 }
}

Verify carries the number and the digits, never the request id:

curl -X POST https://api.infrai.cc/v1/sms/verify \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{ "to": "+447700900123", "code": "220519" }'
{
  "ok": true,
  "data": { "verified": true, "to": "+447700900123" },
  "metadata": { "cost_usd": 0.005, "vendor": "infrai" }
}

The module

Node 22, ESM, no dependencies. Swap MemoryStore for Redis when you run more than one instance — everything else stays put.

const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is unset (use your_infrai_api_key locally)");

const COOLDOWNS_MS = [30_000, 60_000, 120_000];
const MAX_SENDS = 3;
const MAX_VERIFIES = 5;
const CHALLENGE_TTL_MS = 10 * 60_000;

class MemoryStore {
  #map = new Map();
  get(phone) {
    const rec = this.#map.get(phone);
    if (!rec) return null;
    if (Date.now() - rec.startedAt > CHALLENGE_TTL_MS) { this.#map.delete(phone); return null; }
    return rec;
  }
  set(phone, rec) { this.#map.set(phone, rec); return rec; }
  clear(phone) { this.#map.delete(phone); }
}

const store = new MemoryStore();

async function post(path, body) {
  const res = await fetch(`${BASE}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  const json = await res.json().catch(() => ({}));
  if (res.ok) return json.data;
  const message = json?.error?.message ?? `HTTP ${res.status}`;
  throw Object.assign(new Error(message), {
    status: res.status,
    permanent: /E\.164|format|invalid|not supported/i.test(message),
  });
}

export async function sendCode(phone) {
  const now = Date.now();
  const rec = store.get(phone) ?? { sends: 0, verifies: 0, nextSendAt: 0, startedAt: now };
  if (rec.sends >= MAX_SENDS) return { state: "locked", retryAfterMs: 0 };
  if (now < rec.nextSendAt) return { state: "cooling", retryAfterMs: rec.nextSendAt - now };

  await post("/v1/sms/otp", {
    to: phone,
    template: "{code} is your login code. Do not share it.",
  });

  rec.sends += 1;
  rec.nextSendAt = now + COOLDOWNS_MS[Math.min(rec.sends - 1, COOLDOWNS_MS.length - 1)];
  store.set(phone, rec);
  return { state: "cooling", retryAfterMs: rec.nextSendAt - now, sends: rec.sends };
}

export async function submitCode(phone, code) {
  const rec = store.get(phone);
  if (!rec) return { state: "fresh", verified: false };
  if (rec.verifies >= MAX_VERIFIES) { store.clear(phone); return { state: "locked", verified: false }; }
  rec.verifies += 1;
  store.set(phone, rec);

  const data = await post("/v1/sms/verify", { to: phone, code });
  if (data.verified) { store.clear(phone); return { state: "done", verified: true }; }
  return { state: "open", verified: false, left: MAX_VERIFIES - rec.verifies };
}

const [, , phone, code] = process.argv;
if (!phone) throw new Error("usage: node otp-flow.mjs +447700900123 [code]");
console.log(code ? await submitCode(phone, code) : await sendCode(phone));

Note the ordering in submitCode: the attempt counter increments before the network call, not after. If you increment on the way back, a client that kills the connection mid-request gets unlimited free guesses.

The 503 that will make you send twice

Bad input doesn’t come back as a 400 here. It comes back through the vendor channel with retryable: true attached:

{
  "ok": false,
  "error": {
    "code": "VENDOR_DOWN",
    "http_status": 503,
    "message": "recipient not in E.164 format: '07700900123'",
    "retryable": true,
    "docs_url": "https://docs.infrai.cc/errors"
  }
}

A generic “retry all 5xx three times with backoff” wrapper treats that as transient and burns three attempts on input that will never work. Worse, when the 503 is a real transient failure that happened after the message went out, a blind retry sends a second text and bills you twice. That’s why post() above sets permanent from the message string and why the resend counter increments on success only. To be fair, a per-error retryable flag is right most of the time — this is the case where it isn’t.

Check suppression before you resend

A number that’s opted out will swallow every resend silently, and your user will keep pressing the button. One free call tells you:

curl -X POST https://api.infrai.cc/v1/sms/suppression/check \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{ "phone": "+447700900123" }'

And to see everything currently blocked on the account:

curl -s https://api.infrai.cc/v1/sms/suppression/list \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" | jq '.data.items'

Both are free and neither consumes trial credit, so wiring the check into the resend path costs nothing but a round trip.

Rate limits you can’t read off a header

Worth flagging: these routes return no X-RateLimit-* and no Retry-After. When you hit the platform ceiling you get an SMS_RATE_LIMIT error and no machine-readable hint about when to come back, so your own cooldown ladder is also your backoff policy. That’s a real limitation compared with Twilio Verify, which exposes configurable rate-limit buckets as first-class API objects and will do per-service throttling for you. Vonage takes a similar line, running the resend schedule inside its own verification workflow so the client only ever asks for the next step. If phone verification is the only thing you’re integrating and you want those knobs in a dashboard, stick with a specialist.

The trade you’re making on Infrai is the usual one — you write the cooldown, but the same key already reaches the email fallback, the queue that retries a failed send and the usage view that tells you which tenant burned the credit. One integration, one bill, and the state machine above is the only bespoke part.

References

Browse more sms developer guides