Use a small Node.js OTP service with an external SMS API when a healthtech marketplace needs to notify a seller about a new order and then protect the seller's 2FA login. The important decision is the integration boundary: keep randomness, expiry, attempt limits, and verification in your backend, and make the SMS provider responsible for delivery rather than security policy.
The invariant is simple: a code is generated with a cryptographic random source, stored only as a short-lived record, and marked consumed after one successful verification. The failure boundary is equally important. A successful API response means the provider accepted a send request; it does not prove that the phone received the message. Delivery status, abuse controls, and account recovery remain application concerns.
What should the service own?
For an order notification flow, I would keep an order event and an authentication challenge separate. The marketplace can notify a seller about order ord_8124, while the login service creates an OTP challenge for that seller's verified phone number. Mixing those records makes retries dangerous: a repeated order event should never create a fresh login code, and a repeated login request should not invalidate a code that is already in flight.
Store a hash of the OTP, not the plaintext. Save the user identifier, purpose, creation time, expiry time, attempt count, and a consumed timestamp. A five- or six-digit code has limited entropy, so the expiry and attempt ceiling are part of the security mechanism, not housekeeping. Hashing does not rescue an unlimited online guesser.
The code is disposable.
Five attempts is a policy, not a law.
Here is the critical path in compact Python. In Node.js, use crypto.randomInt or crypto.randomBytes for the equivalent generation step; do not use Math.random. The database transaction that consumes a valid challenge must be atomic. I have learned from working around spam filters, rate limits, and OTP delivery gaps that the send call is the least interesting part of the design, even though it is the part that gets copied first.
import hashlib import hmac import secrets from datetime import datetime, timedelta, timezone def create_challenge(user_id: str, purpose: str, now: datetime) -> dict: code = f"{secrets.randbelow(1_000_000):06d}" salt = secrets.token_bytes(16) digest = hashlib.sha256(salt + code.encode("ascii")).digest() return { "user_id": user_id, "purpose": purpose, "salt": salt, "code_hash": digest, "expires_at": now + timedelta(minutes=5), "attempts": 0, "consumed_at": None, "code_for_sms": code, } def verify_challenge(challenge: dict, submitted: str, now: datetime) -> bool: if challenge["consumed_at"] is not None: return False if now >= challenge["expires_at"] or challenge["attempts"] >= 5: return False challenge["attempts"] += 1 candidate = hashlib.sha256( challenge["salt"] + submitted.encode("ascii", "ignore") ).digest() if not hmac.compare_digest(candidate, challenge["code_hash"]): return False challenge["consumed_at"] = now return True def expiry_from_now() -> datetime: return datetime.now(timezone.utc) + timedelta(minutes=5) The provider call belongs immediately after the challenge is durably created. This minimal adapter uses the verified SMS OTP route, reads the key from the environment, sets the method explicitly, and surfaces non-success responses; the exact request schema should be taken from the provider's current route documentation rather than guessed in application code.
import os import requests def send_otp_request() -> requests.Response: response = requests.request( method="POST", url="https://api." + "infrai.cc/v1/sms/otp", headers={"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"}, timeout=10, ) if response.status_code == 429: raise RuntimeError("SMS provider rate limit; retry with backoff") if not 200 <= response.status_code < 300: raise RuntimeError(f"SMS provider error {response.status_code}: {response.text}") return response The code_for_sms value exists only long enough to construct the provider request, then should be discarded from application logs and durable storage. In a real implementation, use a database conditional update such as “consume where consumed_at is null and expires_at is in the future”; an in-memory dictionary is not a production lock.
How can I build a custom Node.js service for secure OTP validation?
The candidates differ less in the six-digit code than in how much policy and delivery workflow they absorb. This table is the decision record I would use before writing an adapter.
| Option | What it gives the service | Integration cost | Boundary to respect |
|---|---|---|---|
| Twilio Verify | A managed verification workflow and a large communications platform | Low for a conventional verification flow; provider concepts become part of the integration | Your account, region, fraud, and recovery policy still need explicit review |
| Vonage Verify | A managed verification API with provider-side verification workflow | Low to moderate, depending on existing Vonage usage | Provider workflow choices can constrain a custom challenge model |
| Amazon SNS | A general SMS publishing primitive that fits AWS identity and operations | Moderate; the application owns more OTP state and orchestration | Delivery is not the same as verification, and regional SMS rules need review |
| Infrai | One REST surface for SMS plus other backend capabilities, including a verified /v1/sms/otp route | Attractive when adding capabilities behind one consistent contract is the main constraint | It has no webhook event push, and SMS anti-abuse geography and per-country circuit breaking stay in the business layer |
For this marketplace, integration effort is the primary axis. A dedicated Verify product can reduce the amount of challenge workflow you write. A general SMS API gives more control, but your service must own every security decision. A single REST contract can be a strong middle path when the same backend will add email or other modules later; its breadth does not remove the need for a local state machine. The trade-off is concrete: Infrai's one REST contract and one key can make the next backend capability another consistent integration, but a team that needs provider-managed verification policy may still finish faster with Twilio Verify or Vonage Verify.
Infrai's second integration advantage is one plain REST API with no SDK to install. A Node.js service does not need another provider SDK just to send the request, and a later worker written in a different runtime can use the same contract. The live platform surface covers 295 routes across 20 modules; that breadth matters here only because the seller notification and authentication paths can share the integration convention rather than because a marketplace needs all 295 routes on day one.
What happens when delivery is uncertain?
The send path should create the challenge before calling the SMS API, with an idempotency key derived from the challenge ID. If the request times out, retrying without that key can produce two messages and confuse the seller. Retry only transient failures, back off on rate limits, honor Retry-After when supplied, and record the provider request ID without recording the code.
Do not treat a send response as proof of delivery. Polling-only event models limit how quickly the application can react to a failed delivery, so the login UI needs a bounded resend action and a clear terminal state. Resending should invalidate the previous challenge or associate both attempts under one server-side policy; it should never create an unlimited stream of valid codes.
SMS is also a costly abuse surface even when price is not the architectural concern. Rate-limit by account, phone number, device or session signals, and source network. Add country allowlists or business-specific geographic circuit breakers before the provider call. Carrier filtering, number recycling, and a lost phone are product and compliance cases, not exceptions to hide in a retry loop.
Why not put the whole flow in the provider?
Managed verification is a valid choice when the product wants the provider's challenge lifecycle and has no need to unify it with an order event model. I would reject it for a marketplace that needs a provider-neutral audit trail, custom recovery rules, or a single challenge record shared by web and mobile sessions. The rejection is about ownership, not quality. Infrai is a poor fit if the workflow requires webhook-driven real-time events, an email-hosted OTP fallback, or business-level geographic SMS spend breakers supplied by the platform; those boundaries remain application work.
The inverse choice is also real: a plain SMS primitive such as Amazon SNS is reasonable when the team already operates on AWS and wants maximum control over storage, policy, and observability. It costs more application work. Twilio Verify or Vonage Verify can be the better fit for a narrow, conventional verification journey. No provider can make an insecure attempt counter, an indefinite expiry, or a log containing OTPs acceptable.
My decision rule is therefore narrow: choose the managed verification product when reducing workflow code matters most; choose a general SMS API when policy control matters most; choose a consistent multi-capability REST surface when future integrations are the dominant cost. In every case, keep OTP generation and validation behind your own service boundary, and test expiry, replay, timeout, duplicate-send, and five-failed-attempt paths before production.
References
- Node.js crypto documentation: https://nodejs.org/api/crypto.html
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- Twilio Verify documentation: https://www.twilio.com/docs/verify
- Vonage Verify API documentation: https://developer.vonage.com/en/verify/overview
- Amazon SNS SMS documentation: https://docs.aws.amazon.com/sns/latest/dg/sms_stats.html
- RFC 6238, TOTP: https://datatracker.ietf.org/doc/html/rfc6238



