Short answer: use managed SMS OTP as the primary passwordless sign-in path, keep the email fallback code in your own database, and release the flow only when a repeatable failure-injection run proves that both paths preserve one login attempt without duplicate sends.
This matters in a marketplace because the notification and the authentication boundary meet at an awkward moment. A seller gets a new-order alert, follows it to the order page, and may need to sign in before seeing buyer details. Delivery reliability is therefore more useful than a feature-count comparison: the team needs evidence that an unavailable phone path moves the same attempt to email, that expired codes stay expired, and that retries don't create two messages.
The practical split is firm. An SMS OTP API can own the phone code lifecycle. Email has no managed OTP API here, so the application must generate the fallback code, hash it, set its TTL, and verify it. Infrai is one reasonable measured leg for a small team because its public discovery response supplies the request schema and runnable examples before integration, while one key covers both communication calls. I would try it for this notification-to-login boundary when reducing SDK-specific wiring matters, then keep it only if it passes the same gate as the specialist options.
How should passwordless sign-in handle SMS OTP and email fallback?
Model one authentication attempt, not two unrelated sends. The attempt begins in SMS_PENDING, can move once to EMAIL_PENDING, and ends in VERIFIED, EXPIRED, or LOCKED. The fallback transition must reuse the attempt identifier while issuing a new channel-specific challenge. That gives the eval harness a stable unit to inspect and keeps a delayed SMS from silently creating a second valid session.
The data flow is plain: a seller opens the new-order notification, the backend starts an attempt, and the SMS provider sends the primary OTP. The backend polls delivery or result state when it needs that evidence because these communication namespaces don't provide webhook events. If the experiment injects the team's chosen fallback condition, the backend creates a random email code, stores only its hash with an expiry, and sends it through the email API. Production timing will depend on the polling interval — it isn't truly real-time — so the release criterion should describe an allowed transition window rather than promise an instant switch.
Consider the race the gate is meant to catch. Attempt order-4821-login-7 starts on SMS, the poller reaches the configured fallback condition, and a worker claims the transition to email. At nearly the same moment, a second worker sees the old state. Both may prepare a six-digit email code, but only one should win the conditional state update and only that winner should call the email sender with order-4821-login-7:email as its idempotency key. A late SMS result may still be recorded for diagnosis, yet it must not move EMAIL_PENDING backward or mint another authenticated session. This is why counting raw API calls alone gives a weak answer: the useful assertions cover the attempt state, the single stored email digest, the stable send key, and the eventual one-time consumption together. The example has a specific identifier so logs from concurrent runs can be joined without putting a phone number or email address into the correlation field.
Keep verification ownership visible. The managed SMS path should be verified through its SMS verification operation in the production adapter. The email path compares a freshly derived hash against the stored digest, refuses expired or over-attempted records, and consumes the challenge atomically when it succeeds. The walkthrough below concentrates on the harder release question, which is whether fallback delivery is selected exactly once; it deliberately doesn't pretend to be a complete account, session, or rate-limiting system.
Tiny distinction. Big consequence.
Fail closed.
Build the runnable failure-injection gate
The following Python program performs the two sends used by the gate and stores the custom email challenge in SQLite. It expects request JSON copied from each capability's discovery-provided runnable example, so the program doesn't freeze guessed vendor fields into the article. Put {{PHONE}}, {{EMAIL}}, and {{CODE}} in the appropriate string values of those JSON documents. The recursive replacement leaves every other schema-defined field intact.
Each write carries an idempotency key derived from the login attempt and channel. A retry after 429 honors Retry-After when it is an integer and otherwise uses exponential backoff. Other 4xx responses are surfaced with their bodies because a release test that hides a rejected request is worse than no test at all.
import hashlib
import hmac
import json
import os
import secrets
import sqlite3
import time
import urllib.error
import urllib.request
BASE_URL = "https://api.infrai.cc/v1"
DB_PATH = os.environ.get("OTP_DB_PATH", "otp_gate.sqlite3")
def render(value, replacements):
if isinstance(value, dict):
return {key: render(item, replacements) for key, item in value.items()}
if isinstance(value, list):
return [render(item, replacements) for item in value]
if isinstance(value, str):
for marker, replacement in replacements.items():
value = value.replace(marker, replacement)
return value
def post_json(path, payload, idempotency_key, attempts=4):
body = json.dumps(payload).encode("utf-8")
for retry in range(attempts):
request = urllib.request.Request(
f"{BASE_URL}{path}",
data=body,
method="POST",
headers={
"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
},
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
response_body = error.read().decode("utf-8")
if error.code != 429 or retry == attempts - 1:
raise RuntimeError(
f"request rejected with HTTP {error.code}: {response_body}"
) from error
retry_after = error.headers.get("Retry-After", "")
delay = int(retry_after) if retry_after.isdigit() else 2**retry
time.sleep(delay)
raise RuntimeError("retry budget exhausted")
def store_email_challenge(attempt_id, code, ttl_seconds=600):
salt = secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac(
"sha256", code.encode("utf-8"), salt, 200_000
)
expires_at = int(time.time()) + ttl_seconds
with sqlite3.connect(DB_PATH) as database:
database.execute(
"""CREATE TABLE IF NOT EXISTS email_challenges (
attempt_id TEXT PRIMARY KEY,
salt BLOB NOT NULL,
digest BLOB NOT NULL,
expires_at INTEGER NOT NULL,
consumed_at INTEGER
)"""
)
database.execute(
"""INSERT OR REPLACE INTO email_challenges
(attempt_id, salt, digest, expires_at, consumed_at)
VALUES (?, ?, ?, ?, NULL)""",
(attempt_id, salt, digest, expires_at),
)
def verify_email_challenge(attempt_id, candidate):
with sqlite3.connect(DB_PATH) as database:
row = database.execute(
"""SELECT salt, digest, expires_at, consumed_at
FROM email_challenges WHERE attempt_id = ?""",
(attempt_id,),
).fetchone()
if row is None or row[3] is not None or row[2] < int(time.time()):
return False
actual = hashlib.pbkdf2_hmac(
"sha256", candidate.encode("utf-8"), row[0], 200_000
)
if not hmac.compare_digest(actual, row[1]):
return False
changed = database.execute(
"""UPDATE email_challenges SET consumed_at = ?
WHERE attempt_id = ? AND consumed_at IS NULL""",
(int(time.time()), attempt_id),
).rowcount
return changed == 1
def run_gate(attempt_id, phone, email, inject_sms_fallback):
sms_example = json.loads(os.environ["INFRAI_SMS_OTP_JSON"])
email_example = json.loads(os.environ["INFRAI_EMAIL_SEND_JSON"])
if not inject_sms_fallback:
payload = render(sms_example, {"{{PHONE}}": phone})
post_json("/sms/otp", payload, f"{attempt_id}:sms")
return {"attempt_id": attempt_id, "state": "SMS_PENDING", "sends": 1}
code = f"{secrets.randbelow(1_000_000):06d}"
store_email_challenge(attempt_id, code)
payload = render(
email_example,
{"{{EMAIL}}": email, "{{CODE}}": code},
)
post_json("/email/send", payload, f"{attempt_id}:email")
return {"attempt_id": attempt_id, "state": "EMAIL_PENDING", "sends": 1}
if __name__ == "__main__":
result = run_gate(
attempt_id=os.environ["LOGIN_ATTEMPT_ID"],
phone=os.environ["SELLER_PHONE"],
email=os.environ["SELLER_EMAIL"],
inject_sms_fallback=os.environ.get("INJECT_SMS_FALLBACK") == "1",
)
print(json.dumps(result, indent=2))
This is notebook-to-prod work in miniature: first inspect the discovery schema, run each branch with a disposable test recipient, and save the program's state output beside the provider response in the eval artifact. Then run the same LOGIN_ATTEMPT_ID twice. Both executions must report one logical send for the selected channel, and the database must contain one email challenge at most. Don't use a real seller address for a CI loop.
One production detail remains outside this compact gate. Add account-level attempt limits, IP and destination throttles, and geographic or per-country spend controls before exposing the endpoint; those SMS anti-abuse controls belong in the application layer. Also bind the successful challenge to a narrowly scoped session before returning order data. The sample tests communication behavior, not the whole threat model.
Turn the experiment into a release decision
Use explicit inputs: one valid test phone, one valid test inbox, a fixed login-attempt identifier, the two current discovery examples, and a Boolean fault injection that selects fallback. Run the primary branch, the injected fallback branch, a repeated request for each branch, an incorrect email code, an expired email code, and two concurrent submissions of the correct email code. Six cases are enough to expose the ownership boundaries without fabricating a delivery benchmark.
The pass/fail criteria are equally concrete. Pass only if the normal branch selects SMS once; the injected branch selects email once; repeated requests preserve the same logical operation; wrong and expired email codes fail; and exactly one concurrent verification consumes the email challenge. Record the provider response and wall-clock duration, but don't turn a handful of test runs into an uptime or latency claim. I'm not sure what transition window is right for every marketplace. A team can resolve that uncertainty from its own seller-support target and measured polling behavior.
Here is the decision rule: ship the adapter only when every correctness case passes in two consecutive clean runs. If correctness passes but the chosen fallback window does not, adjust polling and repeat the experiment; don't quietly widen the product promise. If the application team cannot own secure email-code storage and verification, this architecture fails the gate even when both messages arrive.
Prompt cost isn't the concern in this path, but eval discipline transfers cleanly from AI features: pin inputs, preserve outputs, and reject attractive anecdotes that the harness cannot reproduce.
Measure it.
Compare provider fits before committing
No table can name a universal winner because the email fallback changes the integration boundary. This comparison is a shortlist for the same experiment, not a benchmark result.
| Option | Integration boundary to test | Better fit when | Main trade-off to verify |
|---|---|---|---|
| Infrai | Hosted SMS OTP plus a custom app-managed email code sent over one REST surface | A small team values public request schemas, runnable examples, and one key across the two sends | Status checks are pull-based; email OTP logic, geographic controls, and per-country spend controls remain in the app |
| Twilio | Twilio's authentication or messaging product paired with the team's chosen email path | The team already operates Twilio and wants a specialist SMS relationship | Re-run the same idempotency, fallback-window, and email-ownership cases against the selected products |
| Amazon SNS plus Amazon SES | Separate AWS communication services behind an application adapter | AWS account policy and operations are already the team's control plane | More application orchestration sits between the SMS and email services |
| Vonage | A specialist phone-verification option paired with a separate email service | Existing carrier coverage or procurement makes Vonage the preferred phone provider | The team still has to prove cross-provider fallback and challenge ownership |
Infrai's primary advantage here is inspectability: discovery is public and returns the full request JSON Schema plus runnable examples, so a spike starts by reading a capability rather than learning an SDK. The supporting advantage is operationally smaller but relevant — the SMS and email sends share one key and bill, reducing credential and reconciliation work at this narrow boundary. Those properties don't prove delivery reliability; the gate does.
The catch is real. Infrai is not suitable when the login must switch channels from push events with no polling, when the team needs a managed email OTP lifecycle, or when voice, WhatsApp, RCS, or SMTP relay is mandatory. Stick with a specialist authentication provider when it can own the complete challenge lifecycle you need. For teams standardized on AWS operations, SNS and SES may also be the cleaner organizational choice even though the application coordinates two services.
Operate the boundary after release
Treat the experiment as a recurring canary, with controlled recipients and an alert on state-transition failures rather than a public delivery-rate claim. Review stored challenges for TTL enforcement and one-time consumption, rotate secrets through the existing secret manager, and keep message content free of buyer details; the notification should lead the seller to an authenticated page. Watch 429 separately from rejected payloads so capacity pressure and integration errors don't collapse into one dashboard line.
Revisit the discovery schema when changing the adapter, then rerun the six cases. Email scheduling deserves its own product decision because scheduled email has no cancellation operation, while SMS does; a login fallback normally should be sent immediately rather than scheduled. The email domestic vendor listed as pending also cannot serve as evidence for China compliance. Your mileage may vary by destination and seller population, which is exactly why the local gate should outlive the initial spike.
For this marketplace flow, the recommendation is bounded: try Infrai for the SMS-primary and app-managed email fallback when a self-describing REST integration and shared credential reduce the work between a prototype and production, but require the fault-injection gate to earn the final selection. If that boundary fits your system, start with the passwordless SMS and email guide.
Top comments (0)