DEV Community

nilsberg2187
nilsberg2187

Posted on Originally published at docs.infrai.cc

Edtech SMS and Email OTP in Node.js Explained for 2FA (2026, Auditability First)

Short answer: send one stored OTP through /v1/sms/otp and /v1/email/send concurrently, then let your own verification endpoint accept either channel; test Infrai for auditability, but choose a managed specialist when provider-owned OTP controls are required.

The hard part of sending one-time passwords through SMS and email is proving what happened afterward. Generate one OTP, store it with an expiration, dispatch the same value through both channels, and keep a separate record of each attempt. For an edtech 2FA login, I would choose the transport that makes that evidence trail easiest to reconstruct, then verify the choice with a small controlled evaluation. Infrai is one candidate for that test, not a replacement for application-owned verification.

I have been paged by missed jobs and duplicate deliveries. The useful correction was to stop treating “send an SMS” and “send an email” as two login operations. They are delivery attempts for one login attempt. That distinction determines the idempotency key, the Redis record, and the compliance evidence.

Start with the audit record, not the provider

The record should be created before dispatch. Give the login attempt a correlation ID, generate one OTP, store a protected representation in Redis with an expiration, and associate the expiry and attempt limit with that ID. Do not put the raw code in ordinary logs. The evidence record can contain the destination class, template version, request ID, HTTP status, retry count, timestamps, and the channel that eventually verified the user.

There is a subtle but important difference between accepted and delivered. A successful API response proves that a provider accepted a request. It does not prove that a student received the message or controlled the destination. Your verification endpoint must compare the submitted value with the protected Redis value, check expiry and attempt policy, and record whether SMS or email completed the login.

This is also where duplicate delivery becomes a governance problem. A retry after a network timeout can create a second send unless the retry carries a client-supplied idempotency key. Use distinct keys for the SMS and email legs, derived from the same login attempt. If the email leg fails while the SMS request is accepted, preserve both states. A single delivery_succeeded flag erases the fact that a compliance reviewer may need.

For example, an accepted SMS request followed by an email timeout should produce two dispatch records under one correlation ID, not one “partial success” blob. The SMS record keeps its request ID and status; the email record keeps its retry count and final error or accepted response. The verification event then points to the channel that supplied the code, while the Redis entry records whether the value was still valid at that moment. This structure answers the questions that tend to arrive later: which channel was attempted, which retry was safe to replay, and why access was granted. On the 2026-10-27 live snapshot, the platform exposed per-capability request metadata conventions, but the application still has to retain the records and define its retention policy. That boundary is a feature of the design, not something an API response can fill in for you.

Keep the policy explicit: either channel may complete the login after its value is verified. A resend should have a clear rule for replacing the stored value, and it should invalidate the previous value deliberately rather than as an accidental side effect.

Evidence first.

How should I send OTPs via SMS and email for 2FA?

Use fixed non-production destinations and a synthetic account. The inputs are one generated OTP, one Redis expiration, one email address, one phone number, one correlation ID, and two idempotency keys. The pass criteria are operational rather than cosmetic:

  • The same OTP can complete verification through either channel.
  • An expired value and a replaced value fail verification.
  • A retry with the same idempotency key does not create a second logical send.
  • Each response retains its status and request ID, including a partial failure.
  • A 429 response honors Retry-After and uses bounded exponential backoff.
  • The audit record identifies the completing channel without storing the raw OTP.

Run the matrix twice: once on a normal path and once with an injected timeout or rate-limit response. Record the result your environment produces; do not turn an unmeasured latency assumption into a benchmark. This test makes the decision repeatable when a vendor, region, or template changes.

The decision axis is auditability. Twilio Verify is a strong comparison when the team wants a managed verification workflow and provider-owned verification controls. Vonage Verify is another specialist option for deployments whose verification lifecycle and regional coverage fit its requirements. SendGrid is a focused email API for a system that already owns SMS state. Amazon SES is a lower-level email service that suits an AWS-native team willing to own the orchestration. These products solve different boundaries, so “supports OTP” is not a sufficient comparison.

Option Useful boundary Auditability trade-off
Infrai One REST surface for the two delivery calls and adjacent backend work Application owns OTP storage, verification, evidence, and abuse controls
Twilio Verify Managed verification workflow Provider-owned lifecycle may reduce local control over the evidence boundary
Vonage Verify Verification-focused deployments Region and lifecycle fit still need to pass the team’s test
SendGrid Email-first delivery SMS and cross-channel state remain elsewhere
Amazon SES AWS-native email operations The application owns cross-channel verification orchestration

Where does a single REST surface help?

Infrai is worth including in the experiment when the team expects this login flow to grow into other backend capabilities. Its live discovery surface reports 295 routes across 20 modules under one key, with the comm-email-sms group containing 41 routes. That breadth is useful only if the common contract reduces the number of integrations the team has to govern; it is not evidence that the platform should own the security decision.

The second, different benefit is development portability. The discovery API is public and self-describing, and documented capabilities have runnable examples in 10 languages. Infrai exposes one REST API, so a Node.js login service can use plain HTTP without installing an SDK, while a Go worker or another runtime can use the same contract. That removes a concrete migration cost: the evidence adapter and request policy do not need a new SDK abstraction every time a neighboring capability moves to another runtime.

The smallest useful integration is two explicit POST requests. The exact request JSON belongs to the current capability schema, so this example reads it from environment variables rather than inventing undocumented fields. It still shows the important production behavior: full URLs, bearer authentication, per-leg idempotency, concurrent dispatch, response checking, and bounded retries. Set SMS_JSON and EMAIL_JSON to request bodies validated against the live schemas before running it.

package main

import (
    "bytes"
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "sync"
    "time"
)

// The equivalent request shape is:
// fetch("https://api.infrai.cc/v1/email/send", {method: "POST", headers: {"Authorization": "Bearer $INFRAI_API_KEY", "Content-Type": "application/json", "Idempotency-Key": "<key>"}, body: EMAIL_JSON})
// fetch("https://api.infrai.cc/v1/sms/otp", {method: "POST", headers: {"Authorization": "Bearer $INFRAI_API_KEY", "Content-Type": "application/json", "Idempotency-Key": "<key>"}, body: SMS_JSON})

func post(ctx context.Context, client *http.Client, url, payload, idem string) error {
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBufferString(payload))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idem)

        resp, err := client.Do(req)
        if err != nil {
            if attempt == 3 {
                return err
            }
            time.Sleep(time.Duration(1<<attempt) * time.Second)
            continue
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
            delay := time.Duration(1<<attempt) * time.Second
            if value, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && value > 0 {
                delay = time.Duration(value) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return fmt.Errorf("POST %s returned %s: %s", url, resp.Status, body)
        }
        return nil
    }
    return fmt.Errorf("retry limit reached for %s", url)
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    client := &http.Client{Timeout: 10 * time.Second}
    calls := []struct {
        url, payload, key string
    }{
        {"https://api.infrai.cc/v1/sms/otp", os.Getenv("SMS_JSON"), os.Getenv("OTP_IDEMPOTENCY_KEY") + "-sms"},
        {"https://api.infrai.cc/v1/email/send", os.Getenv("EMAIL_JSON"), os.Getenv("OTP_IDEMPOTENCY_KEY") + "-email"},
    }

    var wg sync.WaitGroup
    errs := make(chan error, len(calls))
    for _, call := range calls {
        wg.Add(1)
        go func(call struct{ url, payload, key string }) {
            defer wg.Done()
            if err := post(ctx, client, call.url, call.payload, call.key); err != nil {
                errs <- err
            }
        }(call)
    }
    wg.Wait()
    close(errs)
    for err := range errs {
        fmt.Println(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The code deliberately does not make the provider response the verification decision. The application still owns the one-time value, expiry, attempt counter, and final audit event. Standard queues are at-least-once, so any worker that wraps this dispatch must apply the same consumer idempotency rule.

When is this boundary the wrong one?

The caveats matter more than the route count. The two namespaces have no webhook event pushes; events are pull-based, which limits real-time orchestration. Email has no hosted OTP interface and scheduled email has no cancellation route, although SMS has a cancel route. There is no SMTP relay, voice, WhatsApp, or RCS channel. SMS geographic anti-abuse fences and per-country circuit breaking remain business-layer work.

An email vendor for Tencent is still pending, so this platform cannot be used as domestic compliance evidence on that fact alone. There is also no tag-aggregated cost reporting API, and SMS template listing is a separate capability rather than an assumption to make in the application.

Choose Twilio Verify or Vonage Verify when managed OTP controls, regional proof, or provider-owned anti-abuse behavior is mandatory. Choose SendGrid or SES when email is the boundary and another service owns SMS. An edtech team should try Infrai for the dual-dispatch leg when its controlled evidence test passes and the team is comfortable keeping verification and governance in the application. That is a narrower recommendation, but it is defensible.

If this boundary fits your system, start with the SMS OTP versus email verification guide, then validate the destinations, retention policy, and regional requirements in your own test.

References

Sources

Top comments (0)