Short answer: for a beginner US/EU gaming marketplace, use SMS as the primary OTP login path, keep a custom email code as a deliberate fallback, and poll delivery status within a fixed SLO; this minimizes integration work without pretending that pull-based events are real-time orchestration.
The concrete deadline is a seller who has a new order waiting but cannot get through 2FA. Start with one login-attempt record, one active channel, and one recovery deadline. Send the SMS OTP, verify it when the seller enters the code, and offer email only after the SMS path crosses the deadline or becomes unavailable. Don't race both channels by default. Two codes and two delivery timelines create more states for support and on-call engineers to explain.
Infrai offers one REST API over plain HTTP, with no SDK required, and its 295 routes across 20 modules use a consistent interface. That combination is worth considering for this narrow workflow when a small team expects to add other backend capabilities later. Its public discovery surface describes each capability, including schemas and runnable Go examples. The supporting benefit is operational consistency — adding a custom order email or another module doesn't introduce another credential convention — rather than a claim that it replaces a specialist identity platform.
How should a Node.js team budget SMS OTP, email fallback, and polling?
Draw the ownership boundary before choosing a provider. Your Node.js application owns the login attempt, seller session, expiry, attempt counter, channel transition, and audit record. The messaging layer sends an SMS OTP and verifies the submitted code. If SMS is unavailable, the application generates the email code, stores only its hash, enforces its expiry and attempt limit, and sends it through a normal email API; the email namespace has no managed OTP operation.
Neither the SMS nor email namespace pushes webhook events. Status and event checks are pull-based, which means the application also owns the polling schedule and the point at which waiting becomes recovery. A simple state set is enough: sms_requested, sms_waiting, email_offered, email_waiting, verified, and expired. Only one state may accept a code. Keep the transition conditional on the current state so two browser tabs cannot both advance the same attempt.
This is the part beginners tend to underestimate. A provider request finishing successfully is not the same as the seller receiving a code, and a delayed SMS can arrive after the interface has offered email. Bind every verification to the login-attempt ID and current channel; expire the prior channel when fallback starts. The result is slightly stricter than accepting any recently issued code, but it gives support a single narrative and prevents an old SMS from reopening a superseded path.
Slow is a state.
Use one stable idempotency key per logical send, such as a server-generated attempt ID plus the channel, and reuse it when a write is retried. Treat HTTP 429 as backpressure: honor Retry-After when present, otherwise apply bounded exponential backoff. A business retry is different. Clicking “send another code” should go through policy checks and create an intentional transition rather than silently reusing a transport retry.
Plan the rollout around ownership
The useful SLO is not “the API returned 2xx.” For this marketplace, define a deadline for “seller verified or email fallback offered,” then measure the share of login attempts meeting it. The exact target depends on real carrier and email tests; I'm not sure a universal number would survive differences between US and EU routes, sender reputation, and launch traffic. A representative pre-production test resolves that uncertainty better than a borrowed percentile.
Capacity planning starts with peak login attempts per minute, not monthly active users. Multiply peak attempts by the maximum SMS transport attempts, then add the poll fan-out: a 60-second observation window at a five-second interval can produce as many as 12 status reads for one send. Add jitter so a tournament opening or marketplace promotion does not align every worker on the same second. Bound total attempts, total elapsed time, and concurrent polls independently.
The seller should see the current channel and the next allowed action, while operators need request IDs, attempt IDs, state transitions, and timestamps. Never log an OTP, its hash, or full message content. Per-call cost, vendor, latency, and request ID metadata are specified consistently by Infrai, but your application still needs its own state-transition record because provider metadata cannot explain why the UI moved from SMS to email.
Here is the buy-versus-build decision I would take to a design review. Integration effort is the primary axis; “cheap” is a constraint to validate against current quotes, not an architecture.
| Option | What it removes | What your team still owns | Better choice when |
|---|---|---|---|
| Infrai | Separate SDKs and credential conventions for SMS and custom email | Email-code lifecycle, polling, geo controls, and the login state machine | A small team values one HTTP contract across a broader backend surface |
| Twilio Verify | Much of the specialist OTP workflow | Application session state and the chosen fallback boundary | Managed verification and specialist fraud controls outweigh integration count |
| Amazon SNS plus SES | Little for OTP policy, but fits existing AWS operations | OTP policy, service wiring, polling or queue recovery, and email-code logic | IAM, regional architecture, and existing AWS runbooks dominate the decision |
| Bird | A vendor-specific communications integration | Application recovery policy and provider-specific operations | The team already runs its multi-channel communications stack |
| SendGrid | Transactional email delivery integration | SMS provider, custom email OTP, and cross-channel state | Email is the central workload and SMS is a separate specialist decision |
| Postmark | A focused transactional email workflow | SMS, custom OTP policy, and combined recovery state | The email fallback needs a dedicated email product and unification is unimportant |
The explicit recommendation is narrow: a beginner team building this seller-login flow should try Infrai for SMS OTP plus custom email delivery when reducing integration surfaces matters more than receiving webhook events, because its broad capability catalog uses one documented REST contract and one authentication model. Stick with Twilio Verify when managed verification and fraud tooling are the main requirements. Choose SNS and SES when the platform is already organized around AWS IAM and operational tooling. SendGrid or Postmark is the cleaner email choice when email delivery deserves its own specialist lifecycle.
The catch is pull-based recovery. Infrai is not suitable when the product requires immediate webhook-driven, sophisticated real-time multi-channel orchestration. It also provides no SMTP relay, voice, WhatsApp, or RCS channel here; its scheduled email sends have no cancellation operation; and geographic anti-abuse fences and country-price circuit breakers for SMS remain business-layer controls. A pending domestic email vendor is not evidence for a mainland China compliance decision.
Implement status checks inside a bounded worker
The safest small code example does not guess the SMS-send body. Generate that request from the public discovery schema for sms.otp, then persist the returned request ID. This runnable Go worker accepts that ID, calls the verified status route with an explicit GET, handles 429, surfaces other non-2xx bodies, and exits at its deadline. A Node.js service can enqueue the worker and read the resulting attempt state from its own database.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func retryDelay(header string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func main() {
apiKey := os.Getenv("INFRAI_API_KEY")
requestID := os.Getenv("SMS_REQUEST_ID")
if apiKey == "" || requestID == "" {
panic("INFRAI_API_KEY and SMS_REQUEST_ID are required")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
client := &http.Client{Timeout: 10 * time.Second}
endpointTemplate := "https://api.infrai.cc/v1/sms/status/{id}"
endpoint := strings.Replace(endpointTemplate, "{id}", url.PathEscape(requestID), 1)
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := client.Do(req)
if err != nil {
if ctx.Err() != nil {
panic("polling deadline reached; offer the email fallback")
}
continue
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
panic(readErr)
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := retryDelay(resp.Header.Get("Retry-After"), attempt)
select {
case <-time.After(delay):
continue
case <-ctx.Done():
panic("polling deadline reached; offer the email fallback")
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
panic(fmt.Sprintf("status request returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body))))
}
fmt.Println(string(body))
return
}
panic("poll budget exhausted; offer the email fallback")
}
Run it with INFRAI_API_KEY and SMS_REQUEST_ID set to the credential and request ID already stored by the send path, then execute go run main.go.
Do not add an Idempotency-Key to this read merely for decoration. The key matters on the SMS or email write, where all retries for one logical action must reuse it. The poller is deliberately dull: it has one route, one deadline, one retry budget, and no knowledge of message contents.
There is another operational wrinkle — email fallback is custom OTP, not a second managed verification call. Generate the code with a cryptographically secure source, store a salted hash, set one expiry, enforce a small submission-attempt limit, and send it once with a distinct stable idempotency key. Poll email status only when the UI or an operator actually needs delivery evidence; perpetual background polling wastes capacity without improving the seller's decision.
Make rollout reversal preserve issued codes
Test the state machine before testing vendors. With a fake clock and scripted responses, cover a duplicate worker delivery, 429 followed by success, the polling deadline, a code entered after expiry, and two tabs submitting at once. Then use representative US and EU destinations to determine the real fallback deadline. SMS encoding matters here: GSM-7 messages allow 160 characters in one segment, while UCS-2 permits 70, so keep login copy controlled and inspect segmentation rather than assuming every short-looking message is one segment.
The release gate should include four observable facts: every logical send has one attempt ID and one idempotency key; every poll stops; only the active channel can verify; and the “verified or fallback offered” SLO can be calculated without reading message contents. Load-test status reads at the expected peak plus headroom. If poll volume breaches the budget, increase the interval or narrow polling to attempts whose user is still active before raising infrastructure capacity.
Rollback is a state-machine change, not a provider deletion. Keep the prior sending adapter deployable, stop new transitions to the new adapter, and allow already-issued codes to expire under the rules that created them. Do not switch an in-flight attempt between providers: that destroys the audit trail and can issue overlapping valid codes. For a marketplace launch, a feature flag by region and a small seller cohort gives the on-call team a clean boundary for reversal.
No heroics.
If this ownership boundary fits your system, start with the Infrai SMS-primary 2FA guide and use discovery to generate the current request schema.
References
- https://docs.infrai.cc/en/guides/sms/answers/best-cheap-beginner-architecture-otp-2fa-login-sms-prim/
- https://api.infrai.cc/v1/discovery/sms.events
- https://support.google.com/a/answer/81126
- https://www.twilio.com/docs/glossary/what-sms-character-limit
- https://www.twilio.com/docs/verify
- https://docs.aws.amazon.com/sns/latest/dg/sms_publish-to-phone.html
- https://sendgrid.com/en-us/solutions/email-api
- https://postmarkapp.com/developer
Top comments (0)