Short answer: for a marketplace seller login, the simplest SMS OTP API is the one that keeps code creation, delivery, rate limiting, and verification behind a small application boundary. Treat US and EU delivery as separate policy inputs, then make retries idempotent. The provider matters, but the contract you own matters more.
I build CLIs and SDKs for other developers, so I count glue code. A five-minute first call is useful; a clever integration that spreads vendor fields through six services is debt. The concrete trigger here is a new order: a seller signs in, receives a one-time code, and then gets an order alert. Login security and notification delivery share a phone number, but they should not share failure handling.
What should a Node.js SMS OTP login boundary own?
The boundary should accept a phone number, a purpose, and a correlation id. It should return an opaque challenge id. Store only a salted code digest, an expiry timestamp, an attempt counter, and the region policy. Never put the raw code in a database row or a log line.
Here is the smallest useful shape. The transport is deliberately generic; swapping an SMS gateway must not change the login handler.
type OtpPurpose = "seller-login" | "order-alert-confirmation";
type Challenge = {
id: string;
expiresAt: number;
attemptsLeft: number;
};
interface SmsOtp {
issue(input: {
phoneE164: string;
purpose: OtpPurpose;
correlationId: string;
}): Promise<Challenge>;
verify(input: { challengeId: string; code: string }): Promise<boolean>;
}
export async function startSellerLogin(
otp: SmsOtp,
phoneE164: string,
requestId: string,
): Promise<Challenge> {
return otp.issue({
phoneE164,
purpose: "seller-login",
correlationId: requestId,
});
}
The code path is intentionally boring. Boring is testable. At the edge, normalize to E.164, reject obviously invalid input, and bind a challenge to the account and purpose. A code for a seller login must not be accepted for an order-alert confirmation.
One short sentence can save an incident: do not retry verification guesses.
How do US and EU rate limits change code verification and retry behavior?
Rate limits are a control plane, not a single number. Use per-account, per-phone, per-IP, and per-device buckets. Keep a tighter bucket for issuing codes than for checking one code, and add a cooldown after repeated failures. The values belong in configuration reviewed by security; they are not universal facts.
Delivery retries need a different rule. A timeout after submitting a send request is ambiguous: the gateway may have accepted it. Attach an idempotency key derived from the challenge id, and retry only the transport operation that is safe to repeat. On a second request, return the same challenge state instead of minting another code. I've seen this boundary get blurred when a frontend treats every timeout as a fresh login: the seller taps twice, the API creates two challenges, and the first text arrives after the second one. The UI then reports a bad code even though the code was valid for a different challenge. Keep the challenge id stable, show the latest expiry, and make the server authoritative. A 30-second resend cooldown and a five-attempt verification cap are example starting points, not promises of universal safety; tune them with abuse data, carrier feedback, and the account value you are protecting.
For the US and EU, record the destination country, carrier response category, and send timestamp. Do not infer a user's location from an IP address alone. Keep retention short and document why phone metadata is collected. Regional data-protection rules can change, so have counsel validate the exact retention and transfer policy.
function canRetrySend(now: number, lastAttempt: number | undefined): boolean {
if (lastAttempt === undefined) return true;
return now - lastAttempt >= 30_000;
}
function backoffMs(attempt: number): number {
const capped = Math.min(attempt, 5);
return Math.min(30_000, 1_000 * 2 ** capped);
}
The common bug is a client that retries the whole login request. That can issue two valid codes, confuse the seller, and create a denial-of-service loop. Retry a bounded operation, surface a stable status to the UI, and let the challenge expire.
No magic.
Measure the journey, not just HTTP success. Useful counters include challenge issuance, delivery accepted, delivery rejected, verification success, verification failure, expiry, and cooldown. Slice them by country and carrier category, but avoid storing message bodies. A p95 latency number without an expiry-rate view hides the real user pain.
Use a correlation id from the login request through the SMS provider callback and the order event. Redact phone numbers in logs. Alert on sudden changes in verification failures or delivery rejection, then sample a few traces for diagnosis. Your mileage may vary: carrier filtering and sender registration differ by route, and a test handset is not a market-wide benchmark.
The integration trade-offs I would accept
| Choice | Good fit | Cost or limit |
|---|---|---|
| Managed verification workflow | Small team that needs a fast first call | Less control over challenge storage and policy details |
| Direct SMS transport plus your own verifier | Teams with an existing identity service | More code, audits, and on-call responsibility |
| Voice or authenticator fallback | Sellers who cannot reliably receive text | Additional enrollment and recovery UX |
The catch is scope. SMS is a possession signal, not a phishing-resistant authenticator; NIST treats stronger authenticators differently. It is not suitable as the only factor for high-risk seller actions such as changing payout details. Keep a stronger step-up method for those actions, and stick with SMS for low-friction sign-in or recovery where the threat model allows it.
At scale, I would add a queue for order alerts, a dead-letter path for permanent delivery failures, and contract tests against a fake gateway. I would also publish one internal SDK that exposes the boundary above, with no provider-specific types leaking out. That is where integration effort stays visible and replaceable.
Top comments (0)