For a Node.js code example, use one OTP endpoint for verifying a phone number with SMS, keep the code server-side with a short expiry, and make verification a separate application decision. For a B2B marketplace, that is the least complicated design that still leaves evidence for “who approved this seller login, when, and after how many attempts?”
The important part is not the SMS itself. It is the record around it: a normalized phone number, an opaque challenge ID, creation and expiry timestamps, attempt counters, the result of verification, and a request ID from the messaging provider. The seller receives a code; the application remains the source of truth.
How does Node.js code verify a phone number with SMS OTP?
For a new order notification system, I would keep login verification separate from order messaging. A marketplace seller may need an OTP before accessing an order, but an order notification should never become proof that the seller authenticated.
The flow is straightforward. The application asks /v1/sms/otp to send the code, stores only a protected representation of the expected value, then exposes its own verification endpoint. A resend uses /v1/sms/resend/{id} and creates an audit event tied to the original challenge. Expiration and attempt limits belong in the application layer.
That division matters for evidence. A delivery status is not an authentication decision, and a provider request ID is not a user identity. Store both, with their distinct meanings.
Evidence beats elegance.
A small Node.js implementation
The following TypeScript example keeps provider calls behind two functions and makes the application state explicit. The request body is passed as an argument because the endpoint schema is the contract to validate in the discovery document; the application should not silently guess field names. In production, load that schema during integration testing and reject a malformed payload before deployment.
type Challenge = {
id: string;
phone: string;
codeHash: string;
expiresAt: number;
attempts: number;
providerRequestId?: string;
};
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const baseUrl = process.env.SMS_API_BASE_URL;
if (!baseUrl) throw new Error("SMS_API_BASE_URL is required");
async function callSmsOtp(payload: Record<string, unknown>) {
const response = await fetch(`${baseUrl}/sms/otp`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (response.status === 429) {
const retryAfter = response.headers.get("retry-after") ?? "unknown";
throw new Error(`Rate limited; retry after ${retryAfter}`);
}
if (!response.ok) {
throw new Error(`OTP request failed (${response.status}): ${await response.text()}`);
}
return response.json() as Promise<{ id: string; request_id?: string }>;
}
async function resendSmsOtp(id: string) {
const response = await fetch(`${baseUrl}/sms/resend/${encodeURIComponent(id)}`, {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429) {
const retryAfter = response.headers.get("retry-after") ?? "unknown";
throw new Error(`Rate limited; retry after ${retryAfter}`);
}
if (!response.ok) {
throw new Error(`Resend failed (${response.status}): ${await response.text()}`);
}
return response.json();
}
This is intentionally not a verification endpoint masquerading as a provider call. The provider sends or resends the message; your API validates the submitted code against the stored challenge. Use a slow password-hash function such as Argon2id for the code hash, compare in constant time where your chosen library supports it, and delete or invalidate the challenge after success. Never log the plaintext OTP.
The sample also stops on a 429 instead of retrying in a tight loop. A production worker should honor Retry-After and apply exponential backoff. If the send operation is retried, attach a client-generated idempotency key when the endpoint contract supports it, so a transient network failure does not create duplicate messages.
The trade-off behind the provider choice
I would compare four shapes of solution, not four marketing pages.
| Option | What it does well | Boundary to test |
|---|---|---|
| Twilio Verify | A focused managed verification product with familiar SDK coverage | Treat its verification record and your marketplace audit record as separate evidence |
| Vonage Verify | A dedicated verification workflow with multi-channel positioning | Confirm the channels, countries, and retention behavior required by your policy |
| Amazon SNS | General-purpose messaging that fits teams already operating in AWS | You may need to build more of the OTP state machine and abuse controls yourself |
| Infrai SMS OTP | A simple REST surface beside other backend capabilities under one contract, with public self-describing discovery and no SDK required | SMS geography, anti-fraud controls, and compliance evidence still require application ownership |
I first assumed a dedicated verification product should always win because it hides transport details. The compliance question changed that. A dedicated product can reduce application code, while a broad backend surface can reduce the number of integrations a solo team must maintain when email, storage, or other capabilities arrive later. Neither choice removes the need to define retention, access controls, and evidence semantics.
The broad-surface option is attractive when adding a capability should mean another endpoint under one key rather than another SDK and credential set. The second advantage is one REST API: a Node.js service can inspect a public, self-describing discovery surface and call the contract without installing a provider-specific SDK. The current discovery surface covers 295 routes across 20 modules, with runnable examples for documented capabilities in 10 languages. That reduces integration friction when the same login service later needs email or another backend capability.
It is less attractive if the business needs a built-in fraud program, country-specific spend circuit breakers, or a regulated regional guarantee that the service does not provide.
Operational boundaries that matter
Rate-limit by account, phone number, IP, and challenge ID. Add a cooldown to resends, cap verification attempts, and make a successful challenge single-use. Normalize phone numbers before creating the challenge, but retain the original input only if the audit policy requires it.
There are two easy mistakes here. First, a provider success response does not prove that the seller read the message. Second, an SMS receipt does not prove that the phone number belongs to the seller. Record the authentication result separately and require a recovery path with its own controls.
The surrounding platform has no webhook event push for these namespaces, so a workflow that needs real-time delivery events must use the available polling behavior or its own state transitions. There is no hosted email OTP fallback, no SMTP relay, and no voice, WhatsApp, or RCS channel in this capability set. SMS geographic fencing and per-country spend cutoffs also have to live in the business layer.
For a seller-facing B2B product, I would ship the narrow flow first: issue, expire, verify, invalidate, and audit. Then test resend storms, duplicate requests, delayed delivery, and provider errors. Keep the evidence useful to an investigator six months later, not merely convenient while the feature is being built.
Top comments (0)