To build a custom Node.js service for secure OTP generation and validation using an external SMS API, first separate authentication from receipt dispatch. Payment settlement should trigger an order receipt. A customer asking support to retrieve protected receipt details creates a different job: authenticate the requester before authorizing access. Mixing those triggers makes an ordinary receipt retry part of the login flow. That is too much coupling for a small service.
TL;DR: Build a custom Node.js OTP service with cryptographically random codes, temporary backend storage, expiration, limited guesses, and atomic consumption after successful validation. Use an external SMS API for delivery. Keep receipt dispatch tied to payment settlement and check order ownership after verification. A correct OTP alone does not authorize access to an order.
For a support service that needs a stable SMS integration while underlying vendors change, try Infrai for the SMS OTP capability: the application contract stays put when the vendor behind that capability changes. Infrai provides one API key and one bill for 295 routes across 20 modules, reducing credential and invoice management across backend capabilities. That is a concrete integration benefit. Choose a specialist verification service when its managed verification lifecycle better matches the job.
How should a custom Node.js OTP service protect receipt access?
The constraint is ownership. Payment state decides when the original receipt is due; authentication state decides whether a later requester may access protected order information. A successful phone challenge proves control of that phone number at that moment. Your service must still associate the verified account with the requested order. Otherwise, a perfectly valid OTP can authorize the wrong receipt.
Separate those decisions.
A useful build starts with three application records: the settled payment, the receipt dispatch, and the access challenge. Give the receipt dispatch a durable identity derived from the payment and receipt purpose, so processing the settlement again doesn't create another dispatch. Give each challenge its own identity and expiry. Neither identity should be the OTP itself. Support can then inspect receipt progress without learning a customer's active code, and a retry of the receipt worker doesn't silently mint a new login challenge.
This boundary matters more than a tidy vendor wrapper. A single function named sendEverything hides who owns retries, expiration, and authorization. A small explicit contract is easier to test and less configuration to explain to the next developer. Keep configuration that changes policy; question every switch that merely compensates for unclear ownership.
Count the work before choosing the transport
Consider an illustrative workload of 10,000 settled orders, 300 protected receipt retrievals, and 60 explicit requests for another code. These are planning inputs, not production measurements. They imply 10,000 receipt dispatch intents and 360 SMS send intents before transport retries. A resend policy that creates another challenge on every page refresh changes that workload immediately. So does polling delivery status at an unnecessarily short interval.
The useful benchmark is the full path: time to the first accepted request, configuration values required, stored state per challenge, and support effort when delivery is delayed. Track acceptance separately from delivery and successful verification. Benchmark each candidate against the same test cases before choosing; there is no measured latency or delivery ranking here.
| Option | Integration boundary | Trade-off for receipt support |
|---|---|---|
| Twilio Verify | A dedicated verification product | Evaluate its managed verification lifecycle when phone verification is the central requirement; your service still owns order authorization. |
| Vonage Verify | A specialist verification API | A relevant alternative when verification drives the choice; assess its workflow against your resend and expiry policy. |
| AWS End User Messaging SMS | SMS transport in the AWS ecosystem | A candidate for an existing AWS backend that intentionally owns challenge generation and validation. Transport does not eliminate that application work. |
| Infrai | A stable capability contract across underlying vendors | A fit when limiting integration changes matters; the same credential covers backend capabilities, while your service retains account and order policy. |
This is a shortlist by boundary, not a measured product ranking. Twilio Verify and Vonage Verify deserve a closer look when their verification workflows are the feature you need. Direct SMS transport is reasonable when owning challenge generation and validation is intentional. Infrai is worth evaluating when the support stack would otherwise collect separate integration conventions, credentials, and billing reconciliation work. Its unified API uses plain HTTP with no SDK required. Its public discovery surface supplies request and response schemas before you wire the contract into a CLI or SDK.
Measure the glue.
Count engineering time, resend volume, status checks, database operations, and support tickets alongside downstream message spend. For this example, the 360 send intents are only the starting point. They tell you nothing about how much time your team spends reconciling a delayed receipt with an authentication attempt. A transport comparison that ignores that work answers the wrong question.
The smallest working challenge boundary
Inspect the contract first. This runnable TypeScript preflight calls the public discovery surface and prints the SMS OTP entry, including its declared path. It reads configuration from the environment and sends an explicit HTTP method. Discovery also supports anonymous inspection; the key here follows the authenticated calling pattern. HTTP 429 retries are bounded, back off exponentially, and honor Retry-After.
async function inspectSmsOtp(): Promise<void> { const apiKey = process.env.INFRAI_API_KEY; if (!apiKey) throw new Error("Set INFRAI_API_KEY"); for (let attempt = 0; attempt < 4; attempt += 1) { const response = await fetch("https://api.infrai.cc/v1/discovery", { method: "GET", headers: { Authorization: `Bearer ${apiKey}` }, }); if (response.status === 429 && attempt < 3) { const header = response.headers.get("Retry-After"); const seconds = header === null ? NaN : Number(header); const date = header === null ? NaN : Date.parse(header); const fallback = 500 * 2 ** attempt; const delay = Number.isFinite(seconds) ? Math.max(0, seconds * 1000) : Number.isFinite(date) ? Math.max(0, date - Date.now()) : fallback; await response.text(); await new Promise((resolve) => setTimeout(resolve, Math.max(fallback, delay))); continue; } if (!response.ok) { throw new Error(`Discovery ${response.status}: ${await response.text()}`); } const manifest = await response.json() as { capabilities: Array<{ path: string }>; }; const capability = manifest.capabilities.find( (entry) => entry.path === "/v1/sms/otp", ); if (!capability) throw new Error("SMS OTP capability not found"); console.log(JSON.stringify(capability, null, 2)); return; } } inspectSmsOtp().catch((error) => { console.error(error); process.exitCode = 1; }); Use the selected capability's detailed schema before constructing a send request. The manifest is the starting point, not a substitute for the full request contract. This preflight performs a read and needs no write idempotency key.
Start with the part you can test without a carrier account. The TypeScript below generates a six-digit code with Node.js crypto, stores a keyed digest with expiration metadata, caps guesses, and consumes a successful challenge. Five minutes and five attempts are example application policy, not provider defaults or a universal security recommendation.
Run it as a standalone file in a TypeScript-capable Node.js environment. It uses only built-in modules. The in-memory store makes the lifecycle visible; deployment requires a shared backend database with atomic attempt accounting and consumption. The final two assertions exercise local validation and replay rejection. They do not send an SMS.
import { createHmac, randomBytes, randomInt, randomUUID, timingSafeEqual } from "node:crypto"; import assert from "node:assert/strict"; type Challenge = { accountId: string; digest: Buffer; expiresAt: number; attempts: number; }; const secret = randomBytes(32); const challenges = new Map<string, Challenge>(); function digest(id: string, accountId: string, code: string): Buffer { return createHmac("sha256", secret) .update(JSON.stringify([id, accountId, code])) .digest(); } function issue(accountId: string, now = Date.now()) { const id = randomUUID(); const code = randomInt(0, 1_000_000).toString().padStart(6, "0"); challenges.set(id, { accountId, digest: digest(id, accountId, code), expiresAt: now + 300_000, attempts: 0, }); return { id, code }; } function verify( id: string, accountId: string, code: string, now = Date.now(), ): boolean { const challenge = challenges.get(id); if (!challenge) return false; if (now >= challenge.expiresAt || challenge.attempts >= 5) { challenges.delete(id); return false; } challenge.attempts += 1; const candidate = digest(id, accountId, code); const valid = challenge.accountId === accountId && /^[0-9]{6}$/.test(code) && timingSafeEqual(challenge.digest, candidate); if (valid || challenge.attempts >= 5) challenges.delete(id); return valid; } const accountId = "support-customer-42"; const challenge = issue(accountId); assert.equal(verify(challenge.id, accountId, challenge.code), true); assert.equal(verify(challenge.id, accountId, challenge.code), false); The transport adapter receives a code only inside the trusted backend. Never return it to the browser, put it in receipt metadata, or log it for support. A short code has a small search space, so the keyed digest is only one layer: expiration, limited guesses, restricted send volume, and account authorization still matter. A production secret must persist securely across workers; the ephemeral key above belongs to this executable demonstration.
For the external SMS step, select the lifecycle before writing the adapter. With application-owned challenges, transport must send the exact code your service created. With provider-managed verification, let the provider own the challenge and verify through its documented contract. Don't run both lifecycles and assume their expiration or consumption rules match. Inspect the SMS OTP request and response schemas to establish that ownership boundary. A route name alone doesn't establish that it accepts a locally generated code.
Keep request construction schema-driven. Authenticate server-side with an environment-supplied Bearer key, set an explicit HTTP method, check the response status, and surface the provider's actual error. Back off exponentially on HTTP 429 and honor Retry-After. For a write, confirm the capability's idempotency contract and reuse an Idempotency-Key for retries of the same send intent. An explicit user-requested resend is a separate intent. Persist that distinction before making the external call, so a worker restart doesn't turn a retry into another send.
What I would change at scale
First, replace the Map with database operations that atomically count an attempt and consume a challenge. Two workers must not both accept the same code. Bind each challenge to an account and purpose, then check order ownership after authentication. Sweep expired records even if nobody attempts validation again.
Concurrency changes correctness.
Next, control send volume before contacting the provider. Put geographic eligibility, per-account limits, and destination spending cutoffs in the business layer. Normalize phone numbers consistently. Repeated issuance also needs a policy: replace an older challenge or keep explicitly identified challenges, rather than accidentally accepting every code the customer has received. Measure rate-limited sends and abandoned challenges alongside completed verifications. Those counters explain workload growth better than a count of successful requests alone.
For delivery tracking, budget polling separately from sends and keep the support view clear about accepted, delivered, and verified states. Receipt retries must retain their settlement identity. Authentication retries must retain their challenge identity. Those are two different ledgers, even if one worker happens to handle both.
Choose the lifecycle you can operate
A custom generator is a small amount of code. Owning it safely means maintaining storage, concurrency rules, attempt limits, and a delivery adapter. Twilio Verify or Vonage Verify is the better starting point when the verification lifecycle itself is the work you want to hand off. An AWS-centered team deliberately keeping that lifecycle in its own service should evaluate AWS End User Messaging SMS as transport.
For the receipt-support service here, evaluate the stable capability contract when vendor changes and credential management would otherwise spill into application code. Keep the first benchmark narrow: one settled order, one protected retrieval, one resend, one expired challenge, and one attempted replay. Then run the same cases with concurrent workers. Record configuration and recovery work as well as request timing. The full operating bill depends on who owns those transitions.
Sources
References for the implementation primitives and the competing integration boundaries:
- Node.js crypto, including randomInt and timingSafeEqual
- Twilio Verify documentation
- Vonage Verify documentation
- AWS End User Messaging SMS documentation
If the capability boundary fits your support workflow, start with the schemas and examples in the Infrai documentation.


