Short answer: for US/EU marketplace login 2FA, choose managed SMS OTP as the primary path because it leaves less authentication state for the application to implement; offer a self-built email verification code only as an explicit fallback.
This is a decision about integration effort, not a claim that SMS is universally safer, cheaper, or more deliverable. The marketplace still owns the login attempt, abuse policy, recovery rules, and the moment at which one challenge supersedes another. A managed OTP operation removes code generation and comparison from that boundary. Email sending alone does not.
Govern challenge generations, not delivery receipts
Start the architecture decision record with five invariants. A challenge is bound to a server-known login attempt; its destination is not accepted again from the browser during verification; it expires; failed guesses are bounded; and successful or superseded challenges cannot be reused. These remain application rules even when a provider manages the SMS code.
The channel switch is the awkward part — and therefore the useful design test. When a shopper asks for email fallback, increment the challenge generation and invalidate the SMS generation. Do not race both channels and accept whichever code arrives first. A delayed SMS must not reopen a login after the email challenge has become authoritative. This one transition defines what "fallback" means.
I first draw this workflow as two delivery arrows. That picture is wrong. The critical path is a state machine with delivery hanging off its edges, and the longest review discussion should be spent where delivery uncertainty, user retry, and authentication state meet: neither the SMS nor email namespace provides webhook event pushes, so observation is pull-based; an orchestrator cannot promise an instant automatic channel switch based on an event it will never receive. It can poll, or it can expose a deliberate fallback action after a product-defined wait. In either case, the transition must be idempotent, the previous generation must become unusable, and concurrent requests must resolve to one current generation. Otherwise a harmless-looking retry creates two live credentials for one login.
Keep the audit row boring: login-attempt ID, destination fingerprint, channel, generation, creation and expiration timestamps, attempt count, terminal state, and the policy reason that allowed fallback. Do not log the code. A provider receipt says something about dispatch, not authentication.
Delivery is an effect. Challenge state is the authority.
There are operational boundaries too. SMS abuse controls require business-layer country allow-lists, geographic fencing, velocity limits, and per-country price circuit breakers. There is no tag-aggregated cost-report API, and SMS templates have no list operation. The available channels do not include voice, WhatsApp, or RCS. The domestic China email vendor remains pending, so this stack is not evidence for China compliance.
Compare the work that remains inside the marketplace
The table compares integration ownership, not slogans. Deliverability still has to be tested against the actual US/EU destination mix; SMS segmentation and email sender requirements can change operational behavior before application code changes at all.
| Option | Integration boundary | Good fit | Reason not to choose it |
|---|---|---|---|
| Twilio Verify | A dedicated managed verification product plus application login state | A team that wants specialized verification tooling and is prepared to integrate its operating model | It adds a direct vendor contract and credential to the authentication service |
| Vonage Verify | A managed verification workflow plus application login state | An organization already operating Vonage communications | Switching organizational tooling may cost more effort than the API work saves |
| Amazon Cognito | Hosted identity flows rather than a narrow delivery adapter | An AWS-centered system willing to place more of identity inside a user-pool model | It is a broader identity decision than choosing an OTP delivery channel |
| SendGrid Email API | Message delivery while code lifecycle remains in the application | A team with established SendGrid sender operations and a secure challenge store | Sending email does not supply a managed OTP lifecycle |
| Postmark Email API | Transactional delivery while code lifecycle remains in the application | A team already using Postmark for transactional mail | The application still owns generation, expiry, guessing limits, and consumption |
| Infrai SMS OTP | Plain REST for managed OTP and verification, with no client SDK to install | A polyglot backend that values one HTTP convention and one credential across a broad backend surface | The application must build geographic fencing, country-price circuit breakers, polling orchestration, and email fallback; public self-describing discovery and runnable examples reduce schema work, while one key across 295 routes in 20 modules reduces credential handling when signup later uses adjacent capabilities |
| Self-built email code | Email delivery plus an application-owned OTP lifecycle | A team with mature transactional-mail and challenge systems, or a cohort for which collecting a phone number is unsuitable | Secret lifecycle, replay prevention, templates, retries, and mailbox operations all remain local work |
Competitor names cannot settle deliverability. Twilio and Vonage are sensible direct evaluations for managed verification. SendGrid and Postmark belong in the evaluation only as delivery components for the self-built email branch, not as evidence that the OTP state machine disappears. Amazon Cognito is a valid alternative when the real decision is to outsource a larger identity boundary.
I'm not sure which channel will complete faster for a particular marketplace cohort without measurements split by country, carrier, and mailbox provider. That uncertainty is genuine. It does not change the amount of authentication machinery each option asks the application to own.
Exercise the HTTP boundary with Python
The verified SMS operations are POST /v1/sms/otp and POST /v1/sms/verify. Their request fields should come from the current public discovery schema, so this runnable adapter accepts a discovery-validated JSON object instead of guessing convenient names such as phone or code. Set INFRAI_BASE_URL, INFRAI_API_KEY, and SMS_OTP_REQUEST_JSON; after the send response, paste a complete discovery-validated verification object at the prompt.
import json
import os
import random
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
BASE_URL = os.environ["INFRAI_BASE_URL"].rstrip("/")
API_KEY = os.environ["INFRAI_API_KEY"]
def retry_delay(retry_after: str | None, attempt: int) -> float:
if retry_after is None:
return min(8.0, 2.0**attempt)
try:
return max(0.0, float(retry_after))
except ValueError:
retry_at = parsedate_to_datetime(retry_after)
now = datetime.now(timezone.utc)
return max(0.0, (retry_at - now).total_seconds())
def post_json(path: str, payload: dict, idempotency_key: str) -> dict:
body = json.dumps(payload).encode("utf-8")
headers = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
}
for attempt in range(5):
request = urllib.request.Request(
f"{BASE_URL}{path}",
data=body,
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
response_body = response.read().decode("utf-8")
if not 200 <= response.status < 300:
raise RuntimeError(
f"HTTP {response.status}: {response_body}"
)
return json.loads(response_body)
except urllib.error.HTTPError as exc:
response_body = exc.read().decode("utf-8")
if exc.code != 429 or attempt == 4:
raise RuntimeError(
f"HTTP {exc.code}: {response_body}"
) from exc
delay = retry_delay(exc.headers.get("Retry-After"), attempt)
time.sleep(delay + random.uniform(0.0, 0.25))
raise RuntimeError("retry budget exhausted")
login_attempt_id = os.environ["LOGIN_ATTEMPT_ID"]
otp_payload = json.loads(os.environ["SMS_OTP_REQUEST_JSON"])
send_result = post_json(
"/v1/sms/otp",
otp_payload,
f"login:{login_attempt_id}:sms:1",
)
print(json.dumps(send_result, indent=2))
verify_payload = json.loads(input("Verification request JSON: "))
verify_result = post_json(
"/v1/sms/verify",
verify_payload,
f"login:{login_attempt_id}:verify:1",
)
print(json.dumps(verify_result, indent=2))
The stable idempotency keys identify logical operations, not individual network attempts. The adapter sets an explicit method, loads credentials from the environment, checks response status, surfaces the 4xx body, and backs off on HTTP 429 while honoring Retry-After. Keep this adapter narrow. Signup handlers should not invent their own retry behavior.
Email fallback requires a different adapter and more local state. Generate the secret with a cryptographically secure source, persist a digest rather than plaintext, enforce expiration and attempt limits, consume it once, and apply the same challenge-generation check. There is no SMTP relay and no managed email OTP operation in this capability set. Scheduled email also lacks a cancellation operation, so it is a poor mechanism for a challenge that may be superseded before dispatch.
This is where "easy" earns a precise meaning.
What should an SMS OTP versus email verification code login test prove?
Use SMS OTP first when a phone number is already an accepted account attribute. It has first-class OTP creation and verification operations, which make the integration smaller than an email path where the marketplace owns code storage, expiration, retries, comparison, and templates. The conclusion is conditional, but it is not evenly split.
The rejected default is email-first for every signup. It creates a security-sensitive subsystem before evidence shows that the marketplace needs it, and it confuses an email send API with a verification API. Keep email as a user-requested fallback, not an invisible race. When fallback begins, increment the challenge generation and make the older SMS unusable.
SMS-first is not suitable everywhere. Stick with an existing email challenge service when it already satisfies the invariants and collecting phone numbers would be disproportionate. Choose Twilio Verify or Vonage Verify when their specialized communications operations fit the team's established tooling. Choose Amazon Cognito when hosted identity is the intended system boundary. A team already operating SendGrid or Postmark can keep that delivery layer for fallback, provided it accepts that the verification lifecycle remains its responsibility.
Cost control is a limitation rather than the recommendation: geographic fences and country-price circuit breakers have to live in marketplace policy. Polling also makes cross-channel orchestration less immediate than a webhook-driven design. Those costs do not reverse the primary decision, but they belong in the acceptance criteria before implementation starts.
No shortcuts.
References
- https://support.google.com/a/answer/81126
- https://www.twilio.com/docs/glossary/what-sms-character-limit
- https://www.twilio.com/docs/verify
- https://developer.vonage.com/en/verify/overview
- https://docs.aws.amazon.com/cognito/latest/developerguide/authentication.html
- https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send
- https://postmarkapp.com/developer/api/email-api
Top comments (0)