DEV Community

JaggerBlack5781
JaggerBlack5781

Posted on

Cheap 2FA SMS API in Node.js — Direct Send or OTP Endpoint

Civic Appointment SMS Routing — Node.js Login OTP Design for Reminder Queues

Short answer: use a purpose-built OTP endpoint for login verification, and keep direct SMS for appointment reminders. Put both behind small Node.js interfaces so the portal can change delivery providers without rewriting its scheduling or identity code.

Job Interface Selection reason
Prove a resident controls a phone OTP endpoint The challenge lifecycle, expiry, and verification policy stay together.
Tell someone an appointment changed Direct SMS send Your application owns the event, template, and idempotency key.
Serve returning users with stronger assurance Authenticator app (TOTP) After enrollment, it does not depend on carrier delivery.

This is a boring recommendation. That is useful when one person runs the SaaS and every infrastructure decision competes with a feature that can produce revenue per hour. I want a release I can ship weekly, so I outsource carrier plumbing and spend the scarce attention on queue rules, accessibility, and auditability. It keeps the direct-send path deliberately dumb: an event enters a queue, a policy-approved template is rendered, and a worker records the provider receipt. The login path stays stricter because it owns identity proof, not appointment content.

How should a public-sector appointment portal split 2FA, SMS API, and Node.js work?

Start by naming the security event. A login challenge asks for proof of possession. A reminder communicates a state change. Both may be text messages, but they have different authorization, retention, and retry rules.

An OTP endpoint should expose a bounded state machine: create a challenge, deliver a code, verify it, then expire or consume it. Your adapter can normalize provider-specific fields into challengeId, expiresAt, and a small set of failure reasons. Never return a provider error body to the browser. Give the resident a generic response and give operators a correlation ID.

Direct send is the right primitive after an appointment is booked, moved, or canceled. The server chooses an approved template and supplies the appointment reference. A caller that can submit arbitrary text to a send endpoint is not an authentication system; it is an expensive messaging capability.

One distinction prevents a lot of support tickets.

Keep it finite.

A replaceable TypeScript boundary for login and reminders

The application should depend on two interfaces, not on a vendor SDK. fetch is enough for a standards-based HTTP adapter, and the same shape works for a self-hosted gateway or a managed service. Keep secrets server-side and validate phone numbers before either call.

type OtpStart = { challengeId: string; expiresAt: string };
type SmsReceipt = { messageId: string };

export async function beginLoginOtp(phone: string): Promise<OtpStart> {
  const response = await fetch(process.env.OTP_START_URL!, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ destination: phone, channel: 'sms' })
  });

  if (!response.ok) throw new Error(`otp_start_${response.status}`);
  return response.json() as Promise<OtpStart>;
}

export async function sendAppointmentUpdate(input: {
  phone: string;
  appointmentId: string;
  revision: number;
  text: string;
}): Promise<SmsReceipt> {
  const idempotencyKey = `${input.appointmentId}:${input.revision}:update`;
  const response = await fetch(process.env.SMS_SEND_URL!, {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      'idempotency-key': idempotencyKey
    },
    body: JSON.stringify({ to: input.phone, text: input.text })
  });

  if (!response.ok) throw new Error(`sms_send_${response.status}`);
  return response.json() as Promise<SmsReceipt>;
}
Enter fullscreen mode Exit fullscreen mode

The endpoint names above are configuration values, not a claim that every service uses the same route. Keep the discovery document or API contract beside this adapter, and test the exact method and path in CI. A surprising number of integrations fail because a team guesses /jobs when the selected API calls the operation /send. For an appointment portal, I also keep the event revision next to the queue record: if a clerk moves an appointment from 10:00 to 10:30 and then corrects the phone number, the worker can see which revision it is sending. A retry of revision 7 must not resurrect revision 6, and a delayed worker must not send a reminder after the appointment was canceled. That means the send decision checks current status, consent, and revision immediately before the HTTP call, while the original event remains in the audit log. The extra read costs a query, but it is cheaper than explaining a wrong-time text to a resident or to an auditor.

The queue owns retries. Store the appointment ID, revision, and destination hash in the job; do not put an OTP value into a queue payload or ordinary logs. On a timeout, retry with backoff and an idempotency key. On a permanent rejection, create a dead-letter record that staff can inspect without seeing the full phone number.

I once treated every 429 as an invitation to retry immediately. The result was a longer queue during the very period residents were trying to confirm times. A bounded policy, such as three attempts over several minutes, is a starting point only; carrier agreements, local rules, and your observed delivery window should set the final values.

Where the simple path stops being sufficient

SMS OTP is reachable, but it is not a high-assurance factor. Delivery can be delayed, a phone number can be recycled, and an attacker may target account recovery. Add rate limits per account, destination, and network signal. Make codes single-use, short-lived, and independent of the appointment identifier so a leaked reminder cannot become a login token.

An authenticator app removes carrier dependency after enrollment. The trade is product work: enrollment, clock drift, backup codes, device replacement, and a staffed recovery route. Public-sector users may share devices or rely on assisted service, so “turn on TOTP” is not a complete plan.

The catch is integration effort. Choose the OTP route when broad reach and a short first release matter most. Choose an authenticator app when policy requires stronger assurance and the service can support recovery. Stick with direct send for reminders; it is not suitable for proving identity.

Your mileage may vary across managed browsers and locked-down government devices. WebOTP can reduce typing on supported browsers, but it is an enhancement; retain a manual entry field and an accessible fallback.

Tests and operating signals that protect the appointment flow

Test transitions, not just a successful message. A useful suite creates a challenge, verifies the wrong value, verifies the right value, repeats the value, waits past expiry, and checks that each path returns the same safe public response. For reminders, enqueue the same appointment revision twice and assert one delivery intent. Advance a fake clock to test expiry and retry timing.

In production, measure delivery latency, verification success, duplicate suppression, queue age, opt-out rate, and recovery requests. Alert on a rising failure ratio without putting a raw number in the alert title. Keep an audit event for who initiated a challenge, which policy version applied, and when it was consumed. Retention should follow the agency's records schedule; message bodies can often be redacted while timestamps and outcomes remain useful.

Email policy does not replace SMS policy. SPF (RFC 7208) helps a domain publish authorized mail senders, but it says nothing about whether an SMS code is valid. Treat those channels as separate controls. If an appointment change is urgent, expose a staffed or voice fallback that does not silently weaken login throttles.

The smallest design that survives real use is two adapters, one queue, explicit expiry, and an audit trail. Build that before adding a second factor or a dashboard full of delivery graphs.

References

Top comments (0)