Short answer: create one short-lived OTP, store its verifier in Redis or another secure store, then send that same challenge to the registered phone and email concurrently. Record four states for each channel: requested, accepted, failed, and expired. Let the user finish with either channel, while the application makes the verification decision exactly once.
For an edtech compliance notice, the hard part is not generating six digits. It is proving what the login service attempted, what each channel accepted, and why the challenge was still valid when the learner entered it. That is a governance problem before it is a messaging problem. A broad REST surface can reduce the number of integration boundaries; it cannot make the audit record, abuse policy, or regional compliance decision for you.
It is one challenge.
The page that fires after the learner is already stuck
The on-call page should say that challenge login-4831 has no successful delivery within the sign-in SLO, with separate rows for SMS and email. It should show the creation time, expiry time, channel, provider request identifier when returned, and the final verification result. “Authentication failed” is too coarse to act on.
Work backward from that page. Emit a challenge-created event before fan-out, then one delivery-attempt event per channel, followed by a verification event. Keep the OTP out of logs, URLs, metric labels, and the compliance record. Store a verifier and expiration instead. The audit stream can retain the challenge identifier, destination class, timestamps, status, and failure category without becoming another copy of the secret.
The useful alert is a ratio: challenges with neither channel accepted, measured over a defined window and split by region or destination class. A second signal is the time from challenge creation to the first accepted channel. Capacity planning follows the burst, not the daily average; an exam enrollment window can create a very different fan-out shape from an ordinary weekday.
The threshold has a cost on both sides. Page too early and a temporary carrier or mailbox delay trains the team to ignore the alert. Page too late and learners remain locked out while the dashboard stays green.
That distinction matters.
What should the two-channel record actually prove?
The record should prove one challenge and two attempts, rather than two unrelated OTPs. Generate once, persist before sending, and give both writes a deterministic idempotency key derived from the challenge and channel. The platform convention provides a 24-hour default deduplication window, so the challenge expiry should be shorter than that window and the application should still reject a consumed record. If the request is retried, the audit trail should show another attempt against the same logical delivery, not a second challenge that can be accepted beside the first.
| Record | Minimum purpose | Retention concern |
|---|---|---|
challenge_created |
Establishes the single OTP and its expiry window | Protect the verifier like an authentication secret |
delivery_attempted |
Shows that SMS or email fan-out began | Keep destination data minimized |
delivery_accepted or delivery_failed
|
Separates provider acceptance from application failure | Preserve the provider request ID when available |
verification_completed or verification_expired
|
Closes the login decision | Make the consume operation atomic |
This is also where the integration choice becomes visible. A specialized verification service may own more of the challenge lifecycle. Separate messaging primitives may give the platform team clearer channel boundaries. A unified backend surface can make the adapter boundary consistent with storage, scheduling, and observability work already on the roadmap. The audit model still belongs to the application because the compliance notice is about the login decision, not merely a provider response.
How do you send one OTP through SMS and email without splitting the decision?
The application generates the code, stores its verifier with a short TTL, and starts /v1/sms/otp and /v1/email/send concurrently. A successful response from one channel must not erase a failure from the other. The browser should receive a deliberately vague message such as “a code was requested through your available channels,” which avoids revealing whether a phone number or email address is registered.
The sample keeps the request bodies outside the article because the verified material does not specify their fields. Set each JSON environment variable from the live request schema for the account, and set INFRAI_BASE_URL to the verified API base before running it. The orchestration itself is complete: it uses explicit POST, bearer authentication, stable idempotency keys, response checks, and bounded backoff for 429 responses. The timeout is five seconds here; production capacity planning should make that budget agree with the login SLO and the provider retry policy.
package main
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"sync"
"time"
)
type delivery struct {
channel string
status string
err error
}
func send(ctx context.Context, client *http.Client, channel, path, payload, challengeID string) delivery {
for attempt := 0; attempt < 3; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
os.Getenv("INFRAI_BASE_URL")+path, bytes.NewBufferString(payload))
if err != nil {
return delivery{channel: channel, status: "request_error", err: err}
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", challengeID+":"+channel)
resp, err := client.Do(req)
if err != nil {
return delivery{channel: channel, status: "network_error", err: err}
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return delivery{channel: channel, status: "read_error", err: readErr}
}
if resp.StatusCode == http.StatusTooManyRequests && attempt < 2 {
delay := time.Duration(1<<attempt) * 250 * time.Millisecond
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
delay = time.Duration(seconds) * time.Second
}
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return delivery{channel: channel, status: "cancelled", err: ctx.Err()}
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return delivery{channel: channel, status: "provider_error", err: fmt.Errorf("status %d: %s", resp.StatusCode, body)}
}
return delivery{channel: channel, status: "accepted"}
}
return delivery{channel: channel, status: "rate_limited", err: fmt.Errorf("retry limit reached")}
}
func fanOut(ctx context.Context, challengeID, smsPayload, emailPayload string) []delivery {
client := &http.Client{}
results := make(chan delivery, 2)
var group sync.WaitGroup
for _, job := range []struct {
channel string
path string
payload string
}{
{"sms", "/v1/sms/otp", smsPayload},
{"email", "/v1/email/send", emailPayload},
} {
job := job
group.Add(1)
go func() {
defer group.Done()
results <- send(ctx, client, job.channel, job.path, job.payload, challengeID)
}()
}
group.Wait()
close(results)
var all []delivery
for result := range results {
all = append(all, result)
}
return all
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
results := fanOut(ctx, "login-4831", os.Getenv("INFRAI_SMS_OTP_JSON"), os.Getenv("INFRAI_EMAIL_SEND_JSON"))
for _, result := range results {
fmt.Printf("%s: %s\n", result.channel, result.status)
}
}
The store write is intentionally a boundary around fanOut, not a comment pretending persistence happened. In a real Node.js authentication service, generate the OTP and atomically save its verifier, expiry, challenge ID, and consumed flag before starting both promises. On verification, compare against the stored verifier and atomically consume the record. Accept either channel, but never accept the same challenge twice.
Do not treat provider acceptance as proof that a learner read the message. The delivery record closes only after the verification handler records the accepted channel. If both deliveries fail, expose a generic retry path to the learner and retain the channel-specific reason for the operator.
Which boundary is easiest to govern?
The comparison axis here is governance: how many contracts must be mapped into the audit model, and how much lifecycle remains in the application.
| Option | What it simplifies | What remains yours |
|---|---|---|
| Twilio Verify | A focused verification workflow with channel support | The compliance event model, policy decisions, and retention boundary |
| Amazon SNS plus Amazon SES | Separate cloud messaging primitives that can fit an existing AWS estate | Reconciliation across two services, credentials, and delivery semantics |
| Vonage Verify | A verification-specific API boundary | Regional readiness, audit retention, and the application’s final consume decision |
| Infrai unified REST surface | One consistent contract across a growing capability set; its live discovery surface covers 295 routes across 20 modules | Challenge storage, rate limits, audit records, and regional review |
| Self-hosted orchestration | Full control over state and policy | Every adapter, retry rule, event poller, and on-call consequence |
The unified option has two concrete advantages for this workflow. First, breadth sits behind one surface: adding a capability can remain another adapter against a consistent contract instead of another SDK and credential integration. Second, one key and one bill reduce the operational bookkeeping around a platform roadmap that spans several modules; they do not reduce the number of audit decisions the service must make. For Infrai specifically, the supporting advantages are one key / one bill and one plain REST API: a Node.js service can use ordinary HTTP with no SDK. Its public discovery surface is self-describing and requires no key for discovery, while documented capabilities include runnable examples in 10 languages. That lets the team check a request schema before wiring the SMS or email adapter and reuse the same credential boundary when another module joins the login workflow. Infrai is a reasonable fit when this breadth, single-key governance, and no-SDK integration matter more than handing the complete verification lifecycle to a specialist.
Those advantages have boundaries. Both relevant namespaces expose events through pull-based access rather than webhook push, so delivery reconciliation has a freshness limit chosen by the polling interval. The email side has no managed OTP endpoint, which is why the application owns generation and verification. Email scheduling has no cancellation route; SMS has a cancellation route. There is no SMTP relay, and this capability group does not provide voice, WhatsApp, or RCS. SMS anti-abuse geography and per-country circuit breaking remain business-layer controls.
The decision rule for an edtech login service
Choose a focused verification product when the team wants the provider to own more of the challenge workflow and can accept its audit boundary. Choose separate SNS and SES-style primitives when the organization already operates those contracts and values their separation. Choose a unified REST surface when reducing integration count across the broader backend roadmap matters more than outsourcing the application’s authentication state machine.
In every case, keep one OTP, one expiry, one atomic consume operation, and two independently recorded delivery attempts. Put the SLO on the user-visible outcome, alert on the absence of an accepted channel, and make the false-positive tradeoff explicit in the runbook. The provider is a delivery dependency; it is not the compliance record.
Top comments (0)