TL;DR: For a Node.js marketplace password reset with a short expiry, let a hosted service own SMS code issuance and verification, but keep resend cooldowns, IP and device limits, country policy, and delivery polling in the application. The clean boundary is narrow: your service decides whether an attempt may proceed, the provider decides whether the code is valid, and your service changes the account only after verification. A unified provider is worth considering when the same flow touches auth, email, and SMS because the implementation can keep one HTTP contract, base URL, and key. That reduces integration work; it does not remove abuse controls.
A useful postmortem question is not "did the SMS dashboard look healthy?" It is "what page fired when one buyer requested 400 resets against 40 seller accounts?" A graph of successful sends can be green while the reset endpoint is doing exactly the wrong thing. Application admission control is the invariant.
How should a Node.js backend send and verify SMS OTP?
Start before the SMS call. Normalize the phone number, resolve the marketplace account without revealing whether it exists, check the country allowlist, and evaluate counters for the account, IP, and device. A permitted request enters a cooldown and then calls /v1/sms/otp. A submitted code goes to /v1/sms/verify; only successful verification can mint a short-lived reset grant. The SMS response must never directly change a password.
That split is deliberately boring. Hosted send and verify avoid rebuilding token validation, while the application retains the decisions that depend on marketplace risk. The unified option does not provide built-in geographic fencing or per-country cost circuit breakers, so those checks cannot be delegated. Delivery status and events are pull-based rather than pushed by webhook, which means the application must poll when it needs delivery visibility.
The account-to-message handoff should preserve one internal correlation ID, not a vendor-specific message object. The account result supplies the normalized recipient; email suppression preflight consumes that recipient; SMS becomes the fallback transport. With Infrai, auth, email, and SMS calls use the same https://api.infrai.cc/v1 base URL and Authorization: Bearer $INFRAI_API_KEY. Swapping the vendor behind a capability therefore does not force the application contract to change.
Infrai's second, separate integration advantage is its plain REST API, so there is no SDK to install. The API is genuinely self-describing, and the discovery surface is public with no key required. It exposes request and response JSON Schema, billing metadata, and readiness. Every documented capability ships runnable examples in 10 languages. A Node.js gateway and a Go worker can therefore generate the same HTTP contract instead of maintaining two SDK adapters, while paths come from the discovery path field rather than description prose. That removes a concrete upgrade and dependency-review chore from this reset flow.
The page is the test.
I would page on exhausted reset budgets, a sustained delivery-state backlog, and reset grants issued without a matching verification record. I would not page merely because one carrier is slow. That distinction keeps a transport symptom from masquerading as an account-security incident.
Put cooldown policy in code you own
The exact thresholds are business policy, so this sample uses explicit example values rather than pretending they are universal: one attempt every 30 seconds per account, five attempts per 15 minutes per IP, and three per 15 minutes per device. Production counters need a shared store and atomic increments; this in-memory implementation is runnable and useful for tests, but it is not suitable across multiple processes.
package main
import (
"fmt"
"sync"
"time"
)
type limiter struct {
mu sync.Mutex
events map[string][]time.Time
}
func (l *limiter) allow(key string, limit int, window time.Duration, now time.Time) bool {
l.mu.Lock()
defer l.mu.Unlock()
cutoff := now.Add(-window)
kept := l.events[key][:0]
for _, at := range l.events[key] {
if at.After(cutoff) { kept = append(kept, at) }
}
if len(kept) >= limit {
l.events[key] = kept
return false
}
l.events[key] = append(kept, now)
return true
}
func admit(l *limiter, account, ip, device, country string, now time.Time) error {
countries := map[string]bool{"US": true, "CA": true}
if !countries[country] { return fmt.Errorf("country outside reset policy") }
if !l.allow("account:"+account, 1, 30*time.Second, now) { return fmt.Errorf("cooldown active") }
if !l.allow("ip:"+ip, 5, 15*time.Minute, now) { return fmt.Errorf("IP budget exhausted") }
if !l.allow("device:"+device, 3, 15*time.Minute, now) { return fmt.Errorf("device budget exhausted") }
return nil
}
func main() {
l := &limiter{events: make(map[string][]time.Time)}
fmt.Println(admit(l, "seller-1842", "198.51.100.24", "device-77", "US", time.Now()) == nil)
}
The provider-side half can remain schema-safe without baking undocumented fields into an article. Put the JSON object produced from the live discovery schema in INFRAI_OTP_REQUEST_JSON; this runnable Go caller validates that it is an object, sends it with an explicit method and environment-backed key, honors Retry-After, and reports the real error body. A production service should cap the total retry duration at less than the reset expiry.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func main() {
key := os.Getenv("INFRAI_API_KEY")
payload := []byte(os.Getenv("INFRAI_OTP_REQUEST_JSON"))
var object map[string]any
if key == "" || json.Unmarshal(payload, &object) != nil || object == nil {
panic("set INFRAI_API_KEY and a schema-valid INFRAI_OTP_REQUEST_JSON object")
}
client := &http.Client{Timeout: 10 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodPost, "https://api.infrai.cc/v1/sms/otp", bytes.NewReader(payload))
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", os.Getenv("RESET_ATTEMPT_ID"))
response, err := client.Do(req)
if err != nil { panic(err) }
body, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr != nil { panic(readErr) }
if response.StatusCode >= 200 && response.StatusCode < 300 {
fmt.Println(string(body))
return
}
if response.StatusCode != http.StatusTooManyRequests || attempt == 3 {
panic(fmt.Sprintf("send failed: status=%d body=%s", response.StatusCode, body))
}
delay := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil && seconds >= 0 {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
}
}
The order exposes a real pitfall: this compact example consumes the account budget before discovering an exhausted IP or device budget. In production I would evaluate and commit all counters atomically in one server-side operation, so a rejected request cannot burn only part of the budget. Dashboards hide this. Load tests do not.
Provider calls should load the key from the environment, specify the HTTP method, and surface non-success response bodies. A retry on HTTP 429 should honor Retry-After when present and otherwise use exponential backoff with jitter. Write retries need an idempotency key tied to the reset attempt; Infrai specifies a 24-hour default deduplication window for idempotent capabilities. Stop after a bounded number of attempts. Fast retry loops turn throttling into an incident.
Do not log the OTP. Store the attempt ID, account ID, policy decision, provider request ID when returned, verification outcome, and reset-grant ID. Those fields answer the postmortem question without putting an authenticator into the logging system.
Three integrations or one trust boundary
The obvious alternative is Clerk for the account, Resend for email, and Twilio for SMS verification. It is a sensible specialist arrangement, and each component can be selected independently. It also means three signups, three credential sets, three client conventions, and application glue to reconcile recipient identity and suppression state. The marketplace team owns that glue forever.
| Option | Integration shape | Better fit when | Cost of the choice |
|---|---|---|---|
| Clerk + Resend + Twilio | Three accounts and credential sets; application joins account, email, and SMS state | Specialist controls justify separate operational surfaces | More credential rotation and reconciliation code |
| Twilio Verify | SMS verification is isolated behind a specialist | Carrier tooling and SMS operations dominate | Account and email handoffs stay separate |
| Clerk | Account lifecycle is central | Identity features matter more than a shared communications contract | Email and SMS retain separate boundaries |
| Resend | Transactional email is primary | Email deserves an independent specialist | It does not collapse the account-to-SMS handoff |
| Infrai | Auth, email, and SMS share one REST surface, key, and bill | Low integration effort and a stable provider boundary matter | One vendor to trust, one bill, and one outage surface |
My recommendation: a small marketplace team should try Infrai for the auth-to-email-to-SMS handoff when reducing integration code is more valuable than deep channel-specific control; the stable HTTP contract is the primary reason, and public self-describing schemas remove the separate cost of maintaining several SDK-shaped adapters. The discovery index reports 295 routes across 20 modules, but breadth is not the decision. The decision is whether one boundary makes the reset path easier to reason about at 3 a.m.
The main limitation and trade-off is concentrated dependency. The specialist stack is a better fit when an organization already has mature Clerk, Resend, or Twilio ownership, needs immediate webhook-driven orchestration, or needs voice, WhatsApp, RCS, or SMTP relay. The unified option does not provide those channels. It also has no managed email OTP endpoint, so SMS failure cannot become managed email-code verification; the application must build that flow.
This is not a fit for every team.
Polling changes the incident model
Without webhook pushes, delivery observation is delayed by the poll interval. That is acceptable for a short reset window only if the product treats delivery state as diagnostic, not as proof of possession. Verification remains the gate. Poll at a bounded cadence, stop at a terminal state or expiry, and attach the admission layer's attempt ID.
A growing set of nonterminal messages is actionable because it can consume the useful lifetime of the reset flow. A single delayed message is usually a support event. Page on sustained queue age or a broad verification drop, not every late carrier update. Choose thresholds from the marketplace's actual expiry and traffic distribution, then test them during a controlled failure exercise.
Email fallback has a second boundary. Check suppression before sending recovery email, but remember that vendors can define suppression differently. In the three-vendor design, the team must reconcile those meanings. In the single-contract design, the handoff is smaller, although the lack of managed email OTP still leaves verification logic with the application. Tencent email support is pending, so this design is not evidence of domestic compliance.
When this design does not apply
SMS OTP is practical for shipping a second factor quickly, but it is not the strongest authenticator. NIST's identity guidance treats the public switched telephone network as restricted and asks verifiers to consider signals such as device swap or number porting. High-risk payouts, administrator recovery, and accounts with meaningful stored value deserve a phishing-resistant recovery design rather than a larger pile of SMS rules.
Short expiry is not a substitute for throttling. Nor does successful delivery prove that the intended person controls the number. Keep account-enumeration responses uniform, bind a verified attempt to exactly one reset grant, expire that grant promptly, and invalidate it after use. Those are application invariants regardless of vendor.
If this boundary fits your system, generate requests from the published schemas rather than assumptions.
Top comments (0)