OTP login with no delivery webhooks: what polling really tells you

Poll-only SMS delivery data changes your login design. Which signal to trust, how often to read it, and how to stop resend abuse — with a Node 22 controller.

A provider without delivery webhooks doesn’t break OTP login — it just moves the delivery signal out of your request path. Poll for operations, never for the user’s next screen: show the code entry field the moment the send is accepted, and let the verify rate, not a carrier receipt, tell you whether the channel is healthy. Infrai’s SMS surface is poll-only by design, so this is the shape you get.

That constraint is smaller than it sounds, and the reason is uncomfortable.

Carrier delivery receipts lie in both directions. A delivered receipt means the handset acknowledged a message, not that a human read a code, and plenty of successful logins happen on numbers whose receipt never came back at all. Your verify success rate is a better health metric than any DLR stream — you just have to build for it deliberately.

What you can actually know, and when

SignalHow you read itWhen it becomes trueWhat it’s good for
Send acceptedThe response to the OTP callImmediatelyRendering the code field, starting the resend timer
Per-message delivery stateGET /v1/sms/status/{id}Seconds to minutes laterOps dashboards, per-carrier failure hunting
Opt-out / hard failureGET /v1/sms/suppression/listWhenever the carrier tells usExcluding numbers that can never receive a code
The only signal that mattersYour own verify success counterWhen the user types the codeAlerting, channel comparison, fallback decisions

Row four is the one to instrument first. Rows one to three are supporting evidence.

Managed OTP trades telemetry for safety

There’s a fork here that most OTP tutorials skip. POST /v1/sms/otp is the managed path: the gateway generates the code, stores it, expires it and counts attempts, and hands you back a request_id plus sent. Your database never holds a recoverable secret, which is the right default for a login form.

POST /v1/sms/send is the unmanaged path: you write the body, you own the code, and you get a message_id — the handle that GET /v1/sms/status/{id} is keyed on.

So the real question behind “no webhooks” is which of those two you picked. Managed OTP buys you correct expiry and attempt limits; the trade-off is that per-message delivery telemetry belongs to the send route.

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/sms/otp" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"to": "+15551234567", "template": "login"}'
{
  "ok": true,
  "data": { "request_id": "smsotp_9TkQm2xBvR7", "sent": true },
  "metadata": {
    "request_id": "req_25e298add8c84c5c8ad5cd0c",
    "vendor": "tencent_sms",
    "cost_usd": 0.007475
  }
}

The poll loop that stays out of the login path

Run it from a worker, keyed on the message_id from a POST /v1/sms/send, and treat it as telemetry rather than a gate. Three reads spread over about 90 seconds catch nearly everything worth catching; hammering it every second buys nothing and will meet SMS_RATE_LIMIT.

curl -sS "https://api.infrai.cc/v1/sms/status/sms_7Qd1mKpR2xVbN8sTfLgA" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": false,
  "error": {
    "code": "SMS_MESSAGE_NOT_FOUND",
    "http_status": 404,
    "message": "no sms message with id 'sms_7Qd1mKpR2xVbN8sTfLgA' in this account's archive",
    "retryable": false
  }
}

That’s what an unknown id looks like, and it’s worth showing because it’s the response you’ll hit first while wiring the worker up. A real id returns state, attempt, last_event, delivered_at and failed_reason. Two operational notes we’ve confirmed against the live API: GET /v1/sms/events/{id}, the fuller timeline, answers VENDOR_NOT_CONFIGURED with HTTP 503 unless an SMS vendor key is hydrated, and no X-RateLimit-* headers are emitted anywhere on this surface — so rate limits can only be handled reactively, by catching the error and backing off.

Resend, cooldowns, and the counter nobody sets

Without webhooks, users click resend more. That’s the actual UX consequence, and it’s also your abuse surface.

Three limits, all enforced by you rather than the API: a cooldown between resends for one phone number (30 seconds is a reasonable floor), a hard cap per number per hour, and a cap per IP or per session. The catch is that verification attempts are billed whether or not they succeed — a verified: false response with reason: "no_code_issued" still costs a call — so an unthrottled verify endpoint is a way for a stranger to spend your credit. Put the throttle in front of the route, not behind it.

Check who’s already unreachable before any of this:

curl -sS "https://api.infrai.cc/v1/sms/suppression/list" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

The login controller, in Node 22

// otp-controller.mjs — issue, resend with cooldown, verify. No webhooks needed.
// Run: INFRAI_API_KEY=your_infrai_api_key node otp-controller.mjs
const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const COOLDOWN_MS = 30_000;
const MAX_SENDS_PER_HOUR = 5;
const attempts = new Map(); // phone -> { last: number, window: number[] }

async function call(method, path, payload) {
  const res = await fetch(`${BASE}${path}`, {
    method,
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: payload === undefined ? undefined : JSON.stringify(payload),
  });
  const json = await res.json();
  if (json.ok === false) {
    const e = json.error;
    throw Object.assign(new Error(e.message), { code: e.code, retryable: e.retryable });
  }
  return json.data;
}

function throttle(phone) {
  const now = Date.now();
  const rec = attempts.get(phone) ?? { last: 0, window: [] };
  rec.window = rec.window.filter((t) => now - t < 3_600_000);
  if (now - rec.last < COOLDOWN_MS) {
    throw new Error(`cooldown: retry in ${Math.ceil((COOLDOWN_MS - (now - rec.last)) / 1000)}s`);
  }
  if (rec.window.length >= MAX_SENDS_PER_HOUR) throw new Error("hourly send cap reached");
  rec.last = now;
  rec.window.push(now);
  attempts.set(phone, rec);
}

export async function issueCode(phone) {
  throttle(phone);
  const data = await call("POST", "/v1/sms/otp", { to: phone, template: "login" });
  return { request_id: data.request_id, resend_after_ms: COOLDOWN_MS };
}

export async function checkCode(phone, code) {
  const data = await call("POST", "/v1/sms/verify", { to: phone, code });
  return data.verified === true;
}

const phone = "+15551234567";
const issued = await issueCode(phone);
console.log("issued", issued);
try {
  await issueCode(phone);
} catch (err) {
  console.log("second request correctly refused:", err.message);
}
console.log("verify result:", await checkCode(phone, "000000"));

The in-memory Map is fine for one process and wrong for three — move it to Redis or a sms_challenges row before you scale out, because a cooldown that only holds on one instance isn’t a cooldown.

What the polling actually costs

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" | head -c 400

curl -sS "https://api.infrai.cc/v1/account/balance" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Verified 2026-07-26 on Infrai: reads are free and rate-limited, so status polling and suppression lookups add nothing to the bill. The message costs $0.007475 and the verify call $0.005, which puts a clean login at roughly $0.0125 and a login where the user needed one resend at about $0.02. New accounts start with $2 free, good for around 267 messages. Rates drift downward and discount campaigns run, so read GET /v1/discovery for today’s figure rather than trusting this paragraph in six months.

Where a webhook provider wins

If you’re running a delivery-analytics product, or you need sub-second reaction to a failed message across millions of sends, polling is the wrong architecture and you should stick with a provider that streams receipts — Twilio, Sinch and Plivo all publish real DLR webhooks. Infrai doesn’t offer an outbound delivery webhook on the SMS surface today, and inbound message retrieval needs a configured vendor key before GET /v1/sms/inbound/list returns anything but a 503. For a login form, though, none of that is on the critical path: the code either gets typed or it doesn’t, and one key covering SMS, email fallback, the queue behind your worker and the error tracking around it is worth more than a receipt stream you’d only read after an incident.

References

Browse more sms developer guides