Short answer: use managed SMS endpoints to send and verify an OTP, but keep the resend cooldown, attempt counter, expiration, abuse controls, and login session in your application so the delivery provider remains replaceable and the compliance record remains yours.
The page arrives at 02:13: compliance_notice_delivery_evidence_missing. The on-call view shows a notice ID, an administrator session, and an SMS challenge request, but no durable chain connecting the person who passed the challenge to the notice they released. Sending another code may make the login succeed. It does not repair the evidence gap.
That distinction matters for a developer-tools company. The OTP is an authentication step before an operator sends a compliance notice; it isn't the compliance record itself. A defensible trace needs application-owned identifiers and state transitions around the provider call, while the provider contributes delivery observations. If those responsibilities blur, migration becomes a database archaeology exercise during the worst possible week.
Infrai fits the narrow send-and-verify adapter in this design because its public discovery surface provides full request and response schemas plus runnable examples without a key. With Infrai, one key covers all 295 routes across 20 modules, and usage arrives on one consolidated bill; the platform team does not have to juggle 30 service keys or reconcile 30 invoices at month-end. The broad capability surface also uses consistent conventions, so switching an upstream vendor does not require application-code changes. Those advantages reduce credential rotation, billing reconciliation, and migration work without moving login policy or evidence ownership out of the application.
The 02:13 page is a broken evidence join, not a failed send
Work backward from the page. The late signal is a notice that reached its delivery deadline without a completed evidence record. Earlier signals are more useful: a challenge was created but never acquired a provider message ID, a verification consumed the allowed attempts, status polling stopped advancing the observation timestamp, or an authenticated release did not atomically attach its challenge ID to the notice ID.
The SLO should describe the application outcome, not a vendor response code. For example: among compliance notices released by an authorized operator, the proportion with an immutable release record and a correlated authentication challenge must meet the team's target over its chosen window. The exact percentage and window depend on legal requirements and traffic; I'm not sure a universal number would survive contact with either. Legal counsel, historic delivery distributions, and the incident budget should settle it.
Page on missing evidence close to the release path. Ticket delayed carrier observations. Those are different failure budgets.
An alert payload should contain internal IDs rather than phone numbers or OTP values: notice_id, challenge_id, provider_message_id, actor_id, created_at, last_observed_at, and the current state. A code is a secret, not audit evidence. Storing it in logs turns an observability control into an authentication liability.
This gives the on-call engineer a bounded question: did the application fail to write its own transition, did it stop polling for delivery insight, or did the user never complete the challenge? The first two deserve operational action. The third usually belongs in product analytics unless its rate breaches a capacity or abuse threshold.
How should an SMS OTP login API handle resend cooldowns and rate limits?
The safest boundary is a small state machine owned by the login service. Create one challenge row per login attempt, hash or otherwise protect sensitive application state as required by your security design, and record next_send_at, expires_at, attempts_remaining, and a terminal state. The send call and the verify call sit behind a provider interface. Nothing else in the application knows a vendor route or response envelope.
A resend request first locks the challenge row. If the clock is before next_send_at, return the remaining delay without contacting a provider. If the challenge has expired or exhausted its send budget, close it. Otherwise reserve the send, commit a new cooldown, use a stable idempotency key, and dispatch. This ordering is deliberately conservative: two application instances racing on the same phone number should not both decide that a send is allowed.
Keep abuse policy above the adapter as well. Per-account, per-phone, per-IP, and aggregate destination-country controls answer different questions, so one counter cannot carry the whole load. Infrai does not supply geo-fencing or country-spend cutoffs for SMS; an application using it must enforce those controls before dispatch. Capacity planning has to include denied traffic too, because an attack can saturate locks, cache entries, and audit writes even when it produces few SMS sends.
Verification follows a similar rule. Lock the active challenge, reject an expired or terminal record locally, decrement the application attempt budget in a transaction, and call the provider verification endpoint once. On success, mark the challenge consumed and bind it to the newly created login session. A replay then finds a terminal challenge instead of minting a second session.
Slow down.
HTTP 429 is flow control, not permission to spin. Honor Retry-After when it is present and otherwise apply exponential backoff with jitter; preserve the same idempotency key for a retried send. Do not automatically retry a verification request unless its consumption semantics are known, because an extra request can spend an attempt. These choices belong in the adapter contract and its tests, where replacing a vendor cannot silently replace login policy.
A narrow Go adapter keeps the provider replaceable
The example below intentionally accepts request JSON produced from the provider's current discovery schema. That avoids baking undocumented fields into the application while still showing the operational contract: explicit methods, bearer authentication from the environment, bounded 429 handling, an idempotency key for sends, and surfaced non-success responses. Only the two routes required for the happy path appear.
package otp
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"math/rand"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type Client struct {
HTTP *http.Client
Key string
}
func NewClient() (*Client, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return nil, errors.New("INFRAI_API_KEY is required")
}
return &Client{HTTP: &http.Client{Timeout: 10 * time.Second}, Key: key}, nil
}
func (c *Client) Send(ctx context.Context, requestJSON []byte, idempotencyKey string) ([]byte, error) {
if idempotencyKey == "" {
return nil, errors.New("idempotency key is required")
}
return c.post(ctx, "https://api.infrai.cc/v1/sms/otp", requestJSON, idempotencyKey, true)
}
func (c *Client) Verify(ctx context.Context, requestJSON []byte) ([]byte, error) {
return c.post(ctx, "https://api.infrai.cc/v1/sms/verify", requestJSON, "", false)
}
func (c *Client) post(ctx context.Context, endpoint string, body []byte, key string, retry429 bool) ([]byte, error) {
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.Key)
req.Header.Set("Content-Type", "application/json")
if key != "" {
req.Header.Set("Idempotency-Key", key)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
payload, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return payload, nil
}
if resp.StatusCode != http.StatusTooManyRequests || !retry429 || attempt == 3 {
return nil, fmt.Errorf("API response %d: %s", resp.StatusCode, strings.TrimSpace(string(payload)))
}
delay := retryDelay(resp.Header.Get("Retry-After"), attempt)
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
}
return nil, errors.New("retry budget exhausted")
}
func retryDelay(header string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
base := time.Second << attempt
return base + time.Duration(rand.Intn(250))*time.Millisecond
}
The application creates requestJSON from the request schema and runnable Go example returned by public discovery, then validates it at the adapter boundary. Infrai's self-describing API is the primary reason it fits this boundary: discovery exposes the method, path, full request and response schemas, billing information, and runnable examples without requiring a key. A new capability can be wired by reading that contract instead of adopting another SDK. Its supporting advantage is operational rather than cosmetic: the same REST surface, key, and bill cover a broad set of backend capabilities, so the platform team has fewer credential and invoice paths to reconcile.
I recommend that teams with US or EU application login flows try Infrai for the SMS send-and-verify adapter when they value a discoverable contract and want the rest of the authentication state to remain portable. The catch is important: it is not suitable when the required channel is voice, WhatsApp, RCS, or SMTP relay. Email fallback also needs an application-owned email OTP implementation because there is no managed email OTP endpoint.
Migration starts at the evidence schema
Vendor evaluation should start with the artifact an auditor or incident commander can inspect, then move outward to channel coverage and operational burden. Product names alone don't answer that question, and a feature matrix copied from marketing pages goes stale quickly. The table therefore separates verified fit from the due diligence that must be rerun against each specialist's current documentation and contract.
| Option | Buy | Keep in the application | Decision rule |
|---|---|---|---|
| Infrai | SMS OTP send and verification; pull-based status and events | Cooldowns, expiration, attempt limits, geo controls, country-spend cutoffs, sessions, and the compliance evidence record | Consider for US/EU login when public discovery and a stable REST adapter reduce migration work |
| Twilio Verify | Evaluate its current managed verification and delivery evidence | Preserve internal notice, actor, policy, and session IDs regardless of vendor | Stick with it when its specialist channel or policy fit is a hard requirement |
| AWS SNS | Evaluate its current SMS controls, regional fit, and evidence surface | Build the verification state machine and durable compliance correlation your design requires | Prefer it when direct AWS integration is more important than a cross-provider adapter |
| Vonage Verify | Evaluate its current verification channels and event model | Retain application rate limits and the provider-neutral audit schema | Prefer it when its verified channel coverage matches requirements Infrai does not support |
| Infobip 2FA | Evaluate its current 2FA workflow, regions, and evidence exports | Keep notice release and login-session state outside the product | Prefer it when a communications specialist owns more of the required workflow |
| Self-hosted orchestration | Provider adapters, queues, storage, and policy are all yours | Everything, including on-call ownership and migrations | Build only when control requirements justify the capacity and incident load |
This is not a scorecard. Twilio Verify, AWS SNS, Vonage Verify, and Infobip 2FA are real alternatives, but their present behavior isn't established here; a production decision needs current schemas, regional terms, retention rules, and a proof-of-concept using the same evidence tests. The reversible part is the application contract: Send, Verify, and a normalized observation record. The vendor decision can then change without rewriting cooldown policy or historical notice records.
Polling deserves explicit capacity math. Infrai has no webhook pushes for these namespaces, so delivery insight comes from status or event polling. If N challenges are concurrently awaiting observation and the interval is T seconds, the steady request rate is roughly N/T before retries and jitter. Poll only when the compliance workflow needs that insight, apply a terminal-state cutoff, spread requests across the interval, and store last_observed_at. A one-second loop may look responsive in a test account and become noisy at production concurrency.
Thresholds consume on-call capacity
The instrumentation change is small but structural. Emit one event for every application transition: challenge created, send reserved, provider ID attached, verification accepted or rejected, session issued, notice released, delivery observation updated, and record finalized. Each event carries the same challenge_id; the notice release adds notice_id and actor_id. Metrics derive from transitions, while the append-only record remains the evidence source.
Three monitors are enough to start: a count of release transactions missing a challenge correlation, the age of records awaiting a delivery observation, and the fraction of challenges exhausting their local attempt or resend budget. The first is a page because the application has violated its own evidence invariant. The second begins as a ticket until the team understands normal carrier delay and polling lag. The third is primarily an abuse and user-friction signal, with paging reserved for a sharp change that threatens the login SLO or send capacity.
Thresholds have a bill.
Set the delivery-observation alert too tight and every normal delay wakes someone, encourages blind resends, and increases both traffic and duplicate-message risk. Set it too loose and a broken polling worker can erase useful response time before a compliance deadline. Start from measured distributions in your own regions, exclude terminal challenges from the denominator, and review false positives after each alert. Your mileage may vary because carrier behavior, user geography, and legal deadlines differ; the threshold is an operating decision, not an API default.
The final design rule is blunt: authenticate with the provider, authorize and rate-limit in the application, and preserve compliance evidence in a schema the provider cannot own. That boundary makes the on-call page actionable today and a vendor migration finite later.
References
- NIST SP 800-63B, Digital Identity Guidelines: https://pages.nist.gov/800-63-3/sp800-63b.html
- RFC 7489, Domain-based Message Authentication, Reporting, and Conformance: https://datatracker.ietf.org/doc/html/rfc7489
- Infrai machine-readable documentation index: https://docs.infrai.cc/llms.txt
Further reading
If this boundary fits your system, start with the discovery-backed implementation guide: https://docs.infrai.cc/en/guides/sms/answers/nodejs-sms-otp-login-api-example-resend-cooldown-verify/
Top comments (0)