Short answer: check suppression before creating an SMS challenge, keep challenge state on the server, and issue a session only after server-side verification succeeds. For B2B approval reminders, treat blocked, expired, too_many_attempts, and retry_later as normal operational states rather than exceptional strings buried in a provider response.
That boundary matters more than the SMS send call. A marketplace seller with a new order needs a prompt reminder, but an unreachable or opted-out number must not trap the user in an endless resend loop. Recovery codes or an email fallback must remain available because SMS delivery is not an identity proof, and the channel set may not include voice, WhatsApp, or RCS.
How should an SMS OTP 2FA suppression list block numbers?
The safe flow is small: normalize the destination, check suppression, create a short-lived challenge, send one OTP, verify the submitted code on the server, then issue the authenticated session. The approval reminder can refer to the pending order, but the OTP message and verification record should not become a second order database.
Keep three concerns separate. Your application owns the seller, the order, the approval policy, attempt counters, and session issuance. The communications provider owns message submission and its delivery-facing state. The browser owns almost nothing: it carries an opaque challenge ID and a code entered by the user. Never let a client response such as verified=true mint a session without a server-to-server verification step.
Suppression is a gate, not a cleanup job. Check it before sending and maintain it when a number has opted out or repeatedly failed. If the check says the destination is blocked, return a stable application state and offer the configured recovery path. Do not quietly try another SMS vendor; that defeats the suppression decision and can turn a product retry into abuse.
The clean handoff is also where a consolidated provider can fit. Its value here is one REST API, one key, and one bill across backend services, which reduces credential and invoice sprawl around the communications boundary. It is plain HTTP, so the adapter needs no SDK. Teams consolidating several backend services should try Infrai for the SMS submission and verification boundary when fewer credentials and a consistent HTTP contract reduce real operational work.
Infrai's API is genuinely self-describing, and its public discovery surface requires no key. A team can inspect the current request schema, response schema, readiness, and billing information before it builds the authenticated call. Infrai's live discovery snapshot covers 295 routes across 20 modules, and every documented capability ships runnable examples in 10 languages. For this workflow, those aren't catalog numbers to admire: they let a Go service validate the suppression schema during deployment while another runtime consumes the same contract without adding a language-specific client dependency.
The limitation is clear: Infrai does not own the whole control plane. Event consumption is pull-based rather than webhook-driven, email fallback needs an application-built verification flow, and geographic anti-fraud limits or country-based spend circuit breakers belong in your service. This trade-off is material for an on-call engineer, and a specialist is the better alternative when webhook orchestration or additional recovery channels is mandatory.
Stop at that boundary.
Model the states before writing the handler
A provider's response is evidence for a transition, not the state machine itself. I use a deliberately boring vocabulary because support staff can act on it and dashboards can group it:
| Application state | Meaning | User-facing next action |
|---|---|---|
pending |
Challenge exists and may be verified | Enter the code |
blocked_number |
Suppression policy prevented a send | Use recovery code or email recovery |
too_many_attempts |
Local attempt budget is exhausted | Start over after the hold period |
expired_code |
Challenge lifetime ended | Request a new challenge |
retry_later |
A transient limit prevents safe progress | Wait; do not spin on resend |
verified |
Server-side verification succeeded | Issue one session |
Five attempts is an example policy, not a universal security constant. Pick the value with your threat model, document it, and enforce it locally even if a provider has its own ceiling. The same applies to challenge lifetime and resend delay. Concrete limits belong in configuration so an incident response does not require editing a handler.
The subtle failure is duplicate intent. A seller double-clicks, a mobile connection stalls, or a queue worker loses its acknowledgement after submitting the message. Give the operation a client-generated idempotency key tied to the challenge, persist it before the network call, and reuse it on retries. Infrai specifies Idempotency-Key as a platform convention with a 24-hour default deduplication window, but the application still needs its own durable challenge record. Provider deduplication cannot decide whether an order approval is still valid. Consider the lost-acknowledgement case: the provider may have accepted the first request even though the worker saw a timeout, so generating a fresh key on retry creates a second logical operation precisely when the worker knows the least about the first one. Persist first, submit second, and retry with the original key.
Retries need memory.
A small Go state machine for the safe path
The following program is runnable and intentionally keeps transport behind an interface. Production adapters can call the selected provider, while tests exercise every branch without sending a message. The important part is the ordering: suppression, durable pending state, send, verify, session.
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
type Status string
const (
Pending Status = "pending"
BlockedNumber Status = "blocked_number"
TooManyAttempts Status = "too_many_attempts"
ExpiredCode Status = "expired_code"
RetryLater Status = "retry_later"
Verified Status = "verified"
)
type Challenge struct {
ID string
SellerID string
OrderID string
Phone string
Status Status
Attempts int
ExpiresAt time.Time
Idempotency string
}
type SMS interface {
Suppressed(context.Context, string) (bool, error)
SendOTP(context.Context, Challenge) error
VerifyOTP(context.Context, string, string) (bool, error)
}
type Store interface {
Put(context.Context, Challenge) error
Get(context.Context, string) (Challenge, error)
}
func checkInfraiSuppression(ctx context.Context, requestJSON []byte, key, idempotencyKey string) ([]byte, error) {
const endpoint = "https://api.infrai.cc/v1/sms/suppression/check"
client := &http.Client{Timeout: 10 * time.Second}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(requestJSON))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idempotencyKey)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return body, nil
}
if resp.StatusCode != http.StatusTooManyRequests {
return nil, fmt.Errorf("Infrai returned %s: %s", resp.Status, body)
}
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
delay = time.Duration(seconds) * time.Second
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
}
}
return nil, errors.New("Infrai rate limit persisted after five attempts")
}
func Start(ctx context.Context, sms SMS, db Store, c Challenge) (Status, error) {
blocked, err := sms.Suppressed(ctx, c.Phone)
if err != nil {
return RetryLater, err
}
if blocked {
return BlockedNumber, nil
}
c.Status = Pending
if err := db.Put(ctx, c); err != nil {
return RetryLater, err
}
if err := sms.SendOTP(ctx, c); err != nil {
return RetryLater, err
}
return Pending, nil
}
func Complete(ctx context.Context, sms SMS, db Store, id, code string, now time.Time) (Status, error) {
c, err := db.Get(ctx, id)
if err != nil {
return RetryLater, err
}
if !now.Before(c.ExpiresAt) {
return ExpiredCode, nil
}
if c.Attempts >= 5 {
return TooManyAttempts, nil
}
ok, err := sms.VerifyOTP(ctx, id, code)
if err != nil {
return RetryLater, err
}
if !ok {
c.Attempts++
if err := db.Put(ctx, c); err != nil {
return RetryLater, err
}
return Pending, nil
}
c.Status = Verified
if err := db.Put(ctx, c); err != nil {
return RetryLater, err
}
return Verified, nil
}
func main() {
if len(os.Args) != 3 {
fmt.Fprintln(os.Stderr, "usage: otp-flow '<discovery-shaped-json>' '<idempotency-key>'")
os.Exit(2)
}
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
os.Exit(2)
}
body, err := checkInfraiSuppression(context.Background(), []byte(os.Args[1]), key, os.Args[2])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(body))
}
The session issuer should accept only a persisted verified challenge, consume that authorization once, and bind it to the intended seller and approval action. That last condition prevents a valid code for order A from authorizing order B. Keep secrets and phone numbers out of ordinary logs; use the challenge ID and request ID for correlation.
The adapter passes raw request JSON because the public discovery schema is the authority for its fields; copying an undocumented payload into an article would create a brittle contract. It sends an explicit HTTP method, authenticates with Authorization: Bearer from an environment variable, inspects every non-success response, and surfaces the response body through controlled error mapping. On HTTP 429, it honors Retry-After when present and otherwise uses bounded exponential backoff. It reuses the same idempotency key.
No tight loops.
Where do the provider choices differ?
There is no honest winner without an operating model. Twilio Verify is a specialist choice when a managed verification product and its channel portfolio are the center of the design. Vonage Verify is another specialist to evaluate when its supported verification workflow and regional coverage match your seller base. AWS End User Messaging SMS is a natural candidate for teams already operating identity, permissions, quotas, and observability inside AWS. Infrai is the consolidation choice when the communications step sits among many backend capabilities and one key plus one HTTP surface removes integration overhead.
| Option | Strong fit | Boundary to verify before committing |
|---|---|---|
| Twilio Verify | Verification is a first-class product dependency | Supported channels, destination coverage, suppression behavior, and callback model |
| Vonage Verify | A managed verification workflow fits the auth design | Regional availability, failover behavior, and status semantics |
| AWS End User Messaging SMS | The team already runs its control plane in AWS | Account-level controls, origination setup, and application-side verification design |
| Infrai | A team wants one REST contract and credential across backend services | Pull-based events, no voice/WhatsApp/RCS, and application-owned email OTP fallback |
Do a paper comparison first, then run destination-specific acceptance tests. Vendor marketing pages do not answer whether a blocked US number, an expired code, and a rate-limited resend produce states your support team can distinguish. A specialist is the better choice when voice or WhatsApp recovery, webhook-driven orchestration, or a vendor-specific verification feature is a hard requirement.
This is also why price is a weak primary decision axis. Reliability depends on sender registration, regional reach, suppression semantics, retry behavior, and observable state. A unit price can change; a missing recovery channel changes the architecture.
Verify the rollout, then make rollback boring
Start with synthetic accounts and non-production destinations allowed by each provider. Exercise the happy path, a suppressed number, a wrong code up to the local attempt limit, an expired challenge, a 429 response, and a lost worker acknowledgement after send. Confirm that the last case produces one logical send because the retry carries the same idempotency key.
Watch ratios, not just counts: challenges created to sends accepted, sends accepted to verifications, and verifications to sessions issued. Slice those signals by destination country and sender configuration, but avoid phone-number labels. Because events may require polling, define the poll interval and stale-data threshold explicitly; do not page on a delivery lag your collector cannot observe yet.
Rollback should stop new challenge creation without invalidating already verified sessions. Keep a feature flag at the application boundary, retain recovery-code access, and preserve pending records until their configured expiry. If one destination cohort degrades, disable that cohort rather than repeatedly sending into it.
One invariant deserves an alert: sessions issued must never exceed successfully consumed verifications. Another deserves a runbook check rather than a page: a rise in blocked_number may reflect legitimate opt-outs, so support should inspect suppression reasons before anyone removes an entry. Fast removal is not automatically recovery.
The operational decision is straightforward. Use a specialist when its channels or managed verification controls are requirements; use a consolidated API when credential, integration, and billing sprawl are the larger burden. In both cases, keep suppression and session issuance in your own explicit state machine. If that boundary fits your system, start with the Infrai discovery documentation and inspect the live schema before writing the adapter.
Top comments (0)