Short answer: for a customer-support SaaS password reset, choose the SMS API that lets you enforce a short, server-side expiry and a strict retry budget, then measure delivery by region and carrier before expanding traffic. The simplest API call is not the simplest reliable system.
I reached that conclusion after treating a reset code like an ordinary notification. The handler generated a six-digit value, sent it, and allowed a fresh send whenever the user tapped “resend.” It looked tidy in Node.js. It also made carrier filtering, delayed messages, and automated abuse indistinguishable from a normal login. A reset message has a very small useful lifetime, so queue delay matters as much as API latency.
The design below keeps the code service-neutral. It works with a direct carrier aggregator, a hosted verification product, or an internal adapter. The decision is about the boundary and the evidence you collect.
What should a simple SMS OTP API guarantee for SaaS login?
Start with guarantees your application can actually verify. An API should return a durable request identifier, expose an acceptance status, and document how idempotency and retry behavior work. “Accepted” means the provider took responsibility for the message; it does not mean a handset displayed it. Your application still needs a delivery event, a timeout policy, and a way to tell the user what to do next.
For US and EU traffic, split metrics by country, carrier when available, and number type. A single global delivery percentage hides a regional failure. Keep the user-visible error generic, but log a reason category such as expired, too_many_attempts, provider_rejected, or delivery_timeout. Do not log the OTP itself.
The verification record should be one-use and bound to the account and purpose. Store a hash of the code, its creation time, an attempt counter, and a send counter. A resend must invalidate the previous code or make the server accept only the newest version. Otherwise a late message can become a valid credential after the user has already requested another one.
Here is the small contract I use at the application edge. The provider adapter can change without changing the login state machine.
type OtpRequest = {
accountId: string;
phoneE164: string;
purpose: "password_reset";
requestId: string;
expiresAt: number;
};
type SmsGateway = {
send(message: { to: string; body: string; idempotencyKey: string }): Promise<{
accepted: boolean;
providerId?: string;
}>;
};
async function sendResetCode(
request: OtpRequest,
code: string,
gateway: SmsGateway,
): Promise<string> {
const result = await gateway.send({
to: request.phoneE164,
body: `Your reset code is ${code}. It expires soon.`,
idempotencyKey: `reset:${request.requestId}`,
});
if (!result.accepted) throw new Error("sms_not_accepted");
return result.providerId ?? request.requestId;
}
The adapter should normalize provider-specific statuses into this narrow result. That keeps a vendor swap from changing security behavior, while still preserving the provider ID for tracing.
How do expiry, rate limit, retry, and code verification interact?
Treat the reset as a state machine with two clocks. The first clock is the code lifetime; the second is the resend cooldown. A five-minute code with a thirty-second resend cooldown does not grant five minutes of unlimited sending. Cap sends per account, per phone, and per network identity over a longer window, then add a daily ceiling for expensive or suspicious traffic.
Retry only failures that are plausibly transient. An HTTP timeout after submission is ambiguous: the provider may have accepted the message. Retrying with a new request can produce two valid-looking codes and double the abuse surface. Use an idempotency key for the same logical send, query status when the API supports it, and issue a new code only after your state machine decides the original attempt is no longer usable.
Verification needs constant-time comparison and an attempt limit. A wrong code should consume an attempt; an expired code should not reveal whether the account exists. Return the same response shape for an unknown account and a known account. That is less friendly to a debugger, but it removes a useful enumeration signal. In a support queue, this distinction shows up when an agent asks why a customer cannot reset a password: the audit trail can say “expired after 300 seconds” or “attempt limit reached,” while the public endpoint still says only that the request could not be completed. That separation takes a little more schema work, yet it prevents a support diagnostic from becoming an account-discovery oracle and gives the on-call engineer a useful next action.
Good.
type Verification = {
codeHash: string;
expiresAt: number;
attempts: number;
maxAttempts: number;
status: "pending" | "used" | "expired";
};
function canVerify(v: Verification, now: number): boolean {
return v.status === "pending" && now < v.expiresAt && v.attempts < v.maxAttempts;
}
function classifyRetry(statusCode: number | undefined): "retry" | "stop" {
if (statusCode === undefined) return "retry"; // network ambiguity; keep the same idempotency key
return statusCode === 408 || statusCode === 429 || statusCode >= 500 ? "retry" : "stop";
}
The exact numbers belong in a policy file and should be tuned from evidence. Start conservatively, then compare completion rate, median delivery time, p95 delivery time, resend rate, and fraud flags for each region. I am not sure a single expiry value will fit every support workflow; a locked-out employee and a routine password change have different tolerance for delay. Your mileage may vary, especially for roaming numbers and local sender-registration rules.
What delivery evidence separates a useful API from a thin wrapper?
Ask for event-level data before signing up for a feature list. You want timestamps for accepted, queued, sent, delivered, and failed states, plus a stable message ID. You also need retention and export rules: support staff may need to prove that a reset was requested without seeing the secret itself.
Run a controlled canary with test numbers on major US and EU carriers. Keep the message body, sender identity, and send time fixed. Compare first-attempt completion with resend completion; a high resend rate often means users are receiving late messages, not that they cannot read the code. Monitor template changes because a longer body, an unfamiliar link, or repeated identical traffic can change filtering behavior.
Operationally, separate the synchronous request from delivery events. The reset endpoint can acknowledge a request quickly and enqueue the send, while a webhook or polling worker updates the delivery record. Put a deadline on that worker. An unbounded retry queue can outlive the code and create a false sense of reliability.
| Signal | What it tells you | Action |
|---|---|---|
| Accepted-to-delivered time | Carrier and downstream delay | Shorten expiry or change route when the tail exceeds the user journey |
| Resend rate by carrier | Late delivery or filtering | Review sender identity and message content |
| Verify failures after delivery | Confusing UX or replay attempts | Check newest-code rules and attempt limits |
| 429 and timeout frequency | Capacity or ambiguous submission | Back off, preserve idempotency, and alert on sustained spikes |
When is a hosted verification service the wrong fit?
The catch is that a managed verification API can hide routing decisions you need to audit. It may be unsuitable when regulations require message records in a particular jurisdiction, when your support team must replay a delivery trail offline, or when a high-volume account needs direct carrier contracts. In those cases, keep the same application contract and switch the adapter or run a self-managed queue.
Direct APIs have their own trade-off: more control means more work around sender registration, regional rules, webhook verification, and incident response. A hosted product may be the better choice for a solo team that cannot operate those controls continuously. Stick with the hosted boundary when it gives you complete event data and a tested failover path; change providers when it cannot explain an accepted message that never reaches the handset.
Do not make price the selection argument. A lower per-message quote is irrelevant if delayed codes drive three resends and a support ticket. Compare total sends per successful verification, operational hours, and the cost of investigating an ambiguous timeout.
A ship-first rollout for customer support resets
Ship one region and one message template first. Store only the minimum audit fields, redact phone numbers in logs, and put dashboards beside the reset funnel rather than in a separate operations project. Add alerts for delivery-tail growth, resend spikes, and verification failures; each one points to a different layer.
Before enabling more countries, test account enumeration responses, replayed codes, clock skew, duplicate webhooks, and a provider outage simulation. Document who can disable sends and how a user can recover through a non-SMS path. SMS is a possession signal, not a universal identity proof; NIST's authenticator guidance makes that distinction important for higher-risk account changes.
The best “simple” SMS OTP API is therefore the one that leaves these controls visible: a small adapter, explicit state, regional evidence, and an exit route. Measure those properties in production, then let the data decide whether simplicity is still serving the reset journey.
Top comments (0)