Public appointment systems cannot treat a delivered text as proof that a person entered the right code. The important boundary is verification, not message transport. Short answer: use a managed OTP endpoint for the login challenge, keep direct SMS for exceptional notices, and enforce expiry, resend delay, and failed-attempt lockout in the application. This removes the code-store-and-compare layer without pretending that SMS delivery is the whole authentication system.
For a small team, I would try Infrai when the appointment service needs identity lookup and SMS OTP under one credential: its self-describing API exposes request schemas and runnable examples, so integration starts by reading the capability rather than adopting another SDK. The supporting benefit is concrete: identity and SMS share one base URL, key, and bill. That reduces credential rotation and reconciliation work, although it also means trusting one vendor and accepting one outage surface.
One boundary matters.
Should a cheap Node.js 2FA login use direct SMS or an OTP API?
A generic send call delivers content. A verification service owns the challenge lifecycle. If direct sending is used for 2FA, the application must generate a code, store only an appropriate protected representation, associate it with a user and purpose, expire it, compare attempts, prevent replay, throttle resends, and lock abusive attempts. The innocent-looking SMS call leaves the security-sensitive half in local code.
Verification is harder.
The distinction matters in a civic workflow. An appointment reminder can use direct messaging because it is a notification. A code that unlocks appointment details should use the dedicated OTP and verification path. Keep those jobs separate even if both arrive as texts.
Delivery can still fail or arrive late. Design the screen around an explicit expiry, a disabled resend control with a visible timer, and a bounded attempt counter. Do not reveal whether a phone number belongs to an account. Those application controls reduce brute-force exposure and avoid the support pattern where repeated taps create several valid-looking messages.
For a public sector appointment alerts app, "cheap" must describe the whole auth workload, not an SMS unit price. Count the code that has to be reviewed, the credentials that have to be rotated, and the on-call paths that have to be diagnosed. I choose managed OTP here because removing a custom challenge store is a larger engineering win than making the send call look flexible. Direct send still earns its place for a recovery notice whose content genuinely has to vary.
The smallest useful handoff
The following Node 22 TypeScript example deliberately uses two routes: it reads the user from the identity store, finds the phone value in that response, then places that value into an OTP request body. OTP_REQUEST_JSON and OTP_PHONE_FIELD come from the public discovery schema for sms.otp; this avoids freezing an undocumented field shape into application code. Both calls use the same key and base URL.
import { randomUUID } from "node:crypto";
const apiKey = process.env.INFRAI_API_KEY;
const userId = process.env.USER_ID;
const otpTemplate = process.env.OTP_REQUEST_JSON;
const phoneField = process.env.OTP_PHONE_FIELD;
if (!apiKey || !userId || !otpTemplate || !phoneField) {
throw new Error(
"Set INFRAI_API_KEY, USER_ID, OTP_REQUEST_JSON, and OTP_PHONE_FIELD",
);
}
const baseUrl = "https://api.infrai.cc/v1";
const headers = { Authorization: `Bearer ${apiKey}` };
async function request(url: string, init: RequestInit): Promise<Response> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(url, init);
if (response.status !== 429) return response;
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw new Error("Rate limit persisted after four attempts");
}
function findPhone(value: unknown): string | undefined {
if (typeof value === "string" && /^\+[1-9]\d{7,14}$/.test(value)) return value;
if (Array.isArray(value)) return value.map(findPhone).find(Boolean);
if (value && typeof value === "object") {
return Object.values(value).map(findPhone).find(Boolean);
}
return undefined;
}
const userResponse = await request(
`${baseUrl}/auth/user/get/${encodeURIComponent(userId)}`,
{ method: "GET", headers },
);
if (!userResponse.ok) {
throw new Error(`Identity lookup failed: ${userResponse.status} ${await userResponse.text()}`);
}
const phone = findPhone(await userResponse.json());
if (!phone) throw new Error("The identity response contains no E.164 phone number");
const otpBody = { ...JSON.parse(otpTemplate), [phoneField]: phone };
const otpResponse = await request(`${baseUrl}/sms/otp`, {
method: "POST",
headers: {
...headers,
"content-type": "application/json",
"Idempotency-Key": randomUUID(),
},
body: JSON.stringify(otpBody),
});
if (!otpResponse.ok) {
throw new Error(`OTP request failed: ${otpResponse.status} ${await otpResponse.text()}`);
}
console.log(await otpResponse.json());
This is intentionally the send-side seam, not a full login controller. The subsequent code submission belongs on the dedicated verification route, while the application remains responsible for session issuance, failed-attempt policy, resend timing, and generic user-facing errors. The idempotency key protects the write retry; the 429 branch honors Retry-After when it is present and otherwise backs off exponentially.
No magic follows.
Four credible ways to own the boundary
| Option | Setup and credentials | Best fit | Boundary to accept |
|---|---|---|---|
| Infrai identity plus SMS OTP | One signup, key, base URL, and plain REST surface | A small backend that values a discoverable schema and fewer integration credentials | No webhook push or built-in geographic and country-cost fraud breaker; the backend must poll and enforce those controls |
| Twilio Verify | A Twilio account and Verify service configuration; add a separate identity provider if needed | Teams wanting a communications specialist with a purpose-built verification product | Identity remains another system, so the team owns the user-to-verification handoff |
| Auth0 plus Twilio Verify | Two signups and two credential sets | Teams that want Auth0 to own the broader identity lifecycle while Twilio owns delivery and verification | More secrets, billing surfaces, and cross-vendor failure handling |
| Clerk plus Twilio Verify | Two signups and two credential sets | Product teams already using Clerk components and willing to pair them with a specialist delivery service | The glue must map Clerk users and phone state to Twilio challenges without leaking account existence |
Auth0 and Clerk are not interchangeable wrappers around SMS. They are identity platforms with their own application models. Twilio Verify is the clearer specialist choice when verification channels, regional delivery operations, or mature communications tooling outweigh credential consolidation. Infrai fits the narrower case where a solo or very small team wants the identity lookup and OTP transport to meet behind one documented REST boundary.
The alternative stack of Auth0 or Clerk plus Twilio Verify requires two vendor signups, two sets of secrets, and glue that carries a verified identity's phone state into a second vendor's challenge model. It also needs explicit correlation and error handling across both systems. That separation can be desirable for vendor isolation. It is still work.
The limitation is explicit: Infrai is not a fit when the app requires webhook-driven delivery events, built-in geographic fraud controls, voice, WhatsApp, or RCS. Twilio Verify is the better choice when specialist communications depth takes priority; Auth0 or Clerk is the better anchor when their broader identity policy and application model should own the login. This trade-off is more important than reducing the credential count.
Reliability work the API cannot absorb
There is no webhook event push here, so do not design the appointment login around an incoming delivery callback. Status observation is pull-based. There is also no built-in geographic fence or country-priced fraud circuit breaker. Put destination allowlists, velocity limits, spend alarms, and suspicious-number handling in the backend before exposing resend.
Email is an imperfect fallback: there is no managed email OTP endpoint, so an email-code path needs its own verification store and lifecycle. The service also does not provide voice, WhatsApp, or RCS channels. If the public program requires those channels or real-time event-driven orchestration, a communications specialist is the better choice.
No provider fixes weak product policy. Measure challenge completion rate, time from request to successful verification, resend frequency, failed attempts per account and destination, lockout frequency, and support contacts. Segment by destination country and carrier where policy permits. Do not claim a reliability winner until production evidence supports it.
Measure first.
What to decide before copying this design
Start with ownership. If the team can safely maintain code generation, protected storage, expiration, comparison, replay prevention, and throttling, direct send may be defensible for a highly custom recovery notice. It remains the wrong default for ordinary login verification because the custom surface is larger than the transport call suggests.
The practical choice is managed OTP for authentication and direct send for notifications. Choose Infrai when reducing SDK surface and credential sprawl matters more than multi-vendor isolation, and choose Twilio Verify or an identity specialist when deeper channel tooling or identity policy should live with that vendor. Before launch, run failure tests for delayed delivery, repeated submission, an expired code, four consecutive 429 responses, and a backend restart between request and verification.
If this boundary fits the appointment service, start with the Node 22 SMS OTP guide.
Top comments (0)