Short answer: choose a simple SMS 2FA flow that sends an OTP, polls delivery status, and lets your backend decide when to retry or offer another login path. This is a good fit when delivery reliability matters more than instant multichannel orchestration and your team is comfortable owning the fallback state machine.
For an e-commerce support portal, that decision has a concrete consequence. A shopper who cannot complete 2FA cannot reach the contact form that routes a refund, delivery, or account question to the right support queue. Treating "OTP accepted by the API" as "customer can sign in" hides the failure that matters.
My evaluation constraint is therefore end-to-end progress, not a successful send request. The backend needs an OTP identifier, a bounded polling loop, a failed-delivery branch, and a separate verification step. It also needs abuse controls. No provider choice removes those product decisions.
What should a simple backend flow do when SMS 2FA delivery fails?
Model the login as a small state machine: requested, delivery_pending, delivered, delivery_failed, then either verified or expired. The exact provider payload can differ, so those are application states rather than claims about response field names. Map the provider's current response schema into them at one boundary in your code.
Start by requesting the OTP. Save the returned identifier against a short-lived login attempt, never against a durable authenticated session. Poll delivery status or events using that identifier. If delivery succeeds, keep the verification form open; if it fails, expose a controlled resend or an alternate login option. Verification remains a distinct backend action, even when the UI makes the sequence feel like one step.
The simple approach is to call send, display a six-digit input, and wait. It is attractive in a notebook because the happy path takes minutes to wire. In production, though, a failed send looks exactly like a slow shopper: both produce an empty input box. Support receives "the code never arrived," the routing form remains inaccessible, and the backend has no evidence for choosing resend versus wait.
Don't retry forever. Bound attempts per login and per destination, expire the challenge, and make the user ask for another code explicitly after the backend reaches its retry policy. OWASP also recommends consistent responses and rate limiting around recovery-style flows so attackers cannot enumerate accounts or flood a user's inbox. The same defensive posture belongs around OTP login. Consider the delayed-first-code sequence before declaring the state machine done: a shopper requests code A, sees no message before the UI deadline, requests code B, and then receives A. The backend needs an explicit rule for which challenge can verify the session, while the UI needs wording that does not encourage entering an older code. This is an application policy, so capture it as an eval case instead of letting arrival timing choose for you.
Poll deliberately.
This is where the constraint bites: there are no webhook event pushes in this stack. Delivery-aware branching is delayed because the backend must poll. For a login flow, resend and verify are the core operations; cancellation is relevant only if you also run scheduled or batched SMS jobs.
A focused Python delivery poller
The example below polls one verified route, GET /v1/sms/status/{id}. It deliberately does not guess at status field names. Instead, it returns the documented JSON response to a mapper that you generate from the live discovery response schema for your chosen capability. That boundary keeps a notebook experiment honest when it becomes a service.
It is runnable with the Python standard library, reads the key from the environment, sets the method explicitly, checks every response, and backs off on 429 while honoring a numeric Retry-After value. A bad request or authentication response includes its body in the raised exception, which is much more useful than a mysterious empty state.
import json
import os
import sys
import time
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import Request, urlopen
BASE_URL = os.environ["SMS_API_BASE_URL"].rstrip("/")
def get_delivery_status(message_id: str, max_attempts: int = 5) -> dict:
api_key = os.environ["INFRAI_API_KEY"]
safe_id = quote(message_id, safe="")
url = f"{BASE_URL}/sms/status/{safe_id}"
for attempt in range(max_attempts):
request = Request(
url,
method="GET",
headers={
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
},
)
try:
with urlopen(request, timeout=10) as response:
body = response.read().decode("utf-8")
return json.loads(body)
except HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == max_attempts - 1:
raise RuntimeError(
f"Status request failed with HTTP {error.code}: {body}"
) from error
retry_after = error.headers.get("Retry-After", "")
delay = float(retry_after) if retry_after.isdigit() else 2 ** attempt
time.sleep(delay)
except URLError as error:
raise RuntimeError(f"Status request could not be completed: {error.reason}") from error
raise RuntimeError("Status polling exhausted its attempt limit")
if __name__ == "__main__":
if len(sys.argv) != 2:
raise SystemExit("Usage: python poll_sms.py MESSAGE_ID")
print(json.dumps(get_delivery_status(sys.argv[1]), indent=2))
Run it after the OTP request has returned its identifier:
export INFRAI_API_KEY="your-key"
export SMS_API_BASE_URL="your-provider-api-base-url"
python poll_sms.py "message-id-from-the-otp-response"
The API is self-describing: public discovery returns a capability's request JSON Schema, response schema, billing data, and runnable examples. That makes wiring a new capability a matter of reading one endpoint instead of adopting another SDK. Infrai is a reasonable option here because the plain REST surface also puts multiple backend capabilities behind one key and one bill. The catch is still polling; the uniform API does not turn pull events into push events.
Keep the mapping tiny. The poller should hand one of your application states to the login service, not leak a vendor-shaped document through controllers, queues, and analytics. That separation is what lets an eval harness replay the same delivered, delayed, failed, and expired cases without sending real messages.
Comparing the provider choices without hiding the trade-off
Twilio Verify, Vonage Verify, and AWS End User Messaging SMS are real alternatives worth putting into the same proof-of-concept. I would not rank them from a feature checklist alone. Delivery performance varies by destination, sender setup, traffic pattern, and account configuration, so your mileage may vary — a replayable production-like evaluation is what resolves the uncertainty.
| Option | What to validate in the same experiment | When it remains a candidate |
|---|---|---|
| Infrai | OTP send, pull-based status/events, verification, and schema-driven integration | You want a plain REST boundary and accept scheduled polling plus backend-owned fallbacks |
| Twilio Verify | Current delivery-event mechanism, destination coverage, sender requirements, and verification lifecycle | Your measured delivery results and operational model fit the login SLO |
| Vonage Verify | Current event/status behavior, destination coverage, sender requirements, and verification lifecycle | Its live behavior performs best for the countries and traffic you actually serve |
| AWS End User Messaging SMS | Current status visibility, regional setup, sender requirements, and verification building blocks | Your team prefers its operational boundary and can assemble the required OTP flow |
This table is intentionally an experiment plan, not a claim that four products expose identical semantics. Freeze a test matrix before evaluating them: destination country, carrier class, time to terminal delivery state, resend count, verification result, and the login outcome shown to the shopper. Otherwise the team will remember a handful of fast messages and call that reliability.
Keep the first pass narrow. One country and one sender configuration can prove that your state transitions work, but it cannot prove global delivery quality.
Where this flow stops being simple
The recommended flow is not suitable when the login decision must react in real time across SMS, voice, WhatsApp, and RCS. This stack has no voice, WhatsApp, or RCS channel, and its email side has no managed OTP endpoint. If an emailed code is a required fallback, you must build that challenge lifecycle yourself; if managed omnichannel orchestration is the product requirement, stick with a provider whose verified current feature set supplies it.
Country controls are another backend responsibility. There is no built-in geographic anti-abuse fence or per-country pricing circuit breaker, so enforce allowed destinations, risk thresholds, rate limits, and spend policy before requesting an OTP. Do it before the provider call, not after a poll reports delivery. A prompt-cost-aware AI team already treats token budgets as executable policy; SMS destination and retry budgets deserve the same treatment.
There are operational limits beyond login. Email has no SMTP relay, scheduled email has no cancellation operation, SMS templates cannot be listed, and cost reporting cannot be aggregated by tag through an API. A pending domestic email vendor must not be treated as evidence for China compliance. None of these boundaries breaks the narrow SMS login flow, but they matter if the architecture diagram quietly expands into a communications platform.
Short version: use this pattern for a bounded SMS challenge, not as a substitute for a cross-channel identity orchestration product.
What to measure before copying this choice
Run the flow through an eval harness before attaching it to the support queue. Measure time from OTP request to a terminal delivery state, the share that remains pending past your UI deadline, failed deliveries by destination class, resend attempts per challenge, verification completion, and how often shoppers choose the alternate path. Keep provider metadata separate from product outcomes so you can change the mapper without rewriting historical evaluations.
Also test the awkward sequence: the first code is delayed, the shopper requests another, and both eventually arrive. Your application must define which challenge remains valid and communicate that choice clearly. The source facts do not specify that policy, and I'm not sure there is one universal answer; the right rule depends on your threat model and the verification semantics you confirm in discovery. What matters is making it explicit and testing it.
Then connect authentication to the e-commerce job that started this exercise. A verified shopper can submit the contact form; category and order context can route it to refunds, shipping, or account support. An unverified shopper gets a bounded retry or alternate-login path, not a form that silently disappears.
Measure that outcome. The API call is only the middle.
Top comments (0)