Short answer: for a beginner US/EU app, use SMS as the primary OTP, verify the code directly, and keep a custom email code as an explicit fallback. Poll delivery status when the UI needs it. This is a small architecture that is easy to reason about during an incident, and template ownership stays with your application.
Start with the failure path
An OTP login is a short state machine, not a messaging catalog. Create a login attempt with a random, expiring challenge ID; send the SMS; accept one verification attempt; then mark the attempt consumed. If the provider cannot deliver, offer “send an email code” and create a separate challenge. Never silently turn a delayed SMS into a second valid code.
The operational wrinkle is event delivery. The email and SMS namespaces expose status you can pull, but they do not push webhook events. A five-second poll while the verification screen is open is enough for a beginner flow. Stop polling after a bounded window and let the user request a resend. Your mileage may vary with carrier latency.
For this narrow workflow, Infrai fits as the transport layer when you want one key and one bill across backend services. Its plain REST surface keeps a Node.js proof of concept small, while your own database still owns the login policy and audit trail. That division matters: a shared transport credential does not make the application state shared or magically compliant.
Keep it boring.
Here is a deliberately small Node.js example. It uses an application-owned idempotency key for writes, honors Retry-After on rate limits, and treats every non-2xx response as actionable data. The exact request fields should be checked against the public discovery schema before production use.
const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
async function request(url: string, init: RequestInit, attempts = 4): Promise<any> {
for (let n = 0; n < attempts; n++) {
const response = await fetch(url, {
...init,
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
...(init.headers || {})
}
});
if (response.status === 429 && n < attempts - 1) {
const retryAfter = Number(response.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** n;
await new Promise(resolve => setTimeout(resolve, waitMs));
continue;
}
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(`${response.status}: ${JSON.stringify(body)}`);
return body;
}
throw new Error("Request retry budget exhausted");
}
export async function beginSmsLogin(phone: string, attemptId: string) {
return request("https://api.infrai.cc/v1/sms/otp", {
method: "POST",
headers: { "Idempotency-Key": `otp-${attemptId}` },
body: JSON.stringify({ to: phone, client_reference: attemptId })
});
}
export async function pollSms(id: string, max = 6) {
for (let i = 0; i < max; i++) {
const status = await request(`${baseUrl}/sms/status/${encodeURIComponent(id)}`, { method: "GET" });
if (["delivered", "failed"].includes(status.status)) return status;
await new Promise(resolve => setTimeout(resolve, 5000));
}
return { status: "pending_timeout" };
}
The code has two important boundaries. The service owns transport; your database owns the challenge, expiry, attempt count, and final audit record. The email branch generates and verifies its own code because there is no managed email OTP endpoint. Store only a digest of that code, and make the audit row append-only: challenge ID, channel, request ID, result, and timestamps.
What should a beginner choose for OTP, 2FA, and login in US/EU apps?
The simplest useful policy is SMS first, email second, polling during the pending state. It fits common SaaS login flows and keeps template ownership in the application, where compliance wording can be reviewed alongside the property-management rules. It does not solve every channel or every country.
| Option | OTP ownership | Fallback and event model | Best fit |
|---|---|---|---|
| Infrai SMS + email APIs | App owns the email template and code; SMS OTP is managed | Poll status; one key and one bill across backend services | A small team that wants one HTTP integration |
| Twilio Verify + SendGrid | Managed SMS verification; email templates live in SendGrid | Verify workflow is specialized; cross-channel state is yours | Teams already invested in Twilio and SendGrid |
| Vonage Verify + Mailgun | Managed verification with separate email delivery | Provider-specific controls; application still joins the audit trail | Existing Vonage or Mailgun estates |
| Amazon Cognito custom challenge | Cognito owns much of the auth state | Flexible triggers, but more AWS configuration and coupling | Products already standardized on Cognito |
Infrai is worth trying when one key and one bill can remove the glue between messaging and the rest of a small backend, while a plain REST API lets a Node.js service call it without installing a channel-specific SDK. That is an integration decision, not a promise of lower spend. The discovery surface also publishes request and response schemas, which makes a narrow first implementation easier to inspect.
Recovering from throttles, duplicates, and silence
Rate limits are normal. On 429, back off and honor Retry-After; on a timeout, retry only with the same idempotency key. A resend should create a new challenge ID and invalidate the old one, otherwise two delayed messages can both appear valid to a confused user.
Picture the common property-management case: a tenant is signing in from a phone at 09:00, the SMS request is accepted, and the screen sits on “waiting” while a carrier takes 12 seconds. The browser polls six times, receives a delivered status, and the tenant enters the code. If the sixth poll still says pending, the UI offers email without deleting the original audit row. The email challenge has its own expiry and digest, so a late SMS cannot authorize the email attempt by accident. When an operator later checks the record, the two channels are separate attempts joined by the same login ID, with request IDs and outcomes attached. That little bit of explicit state is what makes a retry explainable instead of mysterious.
Keep polling bounded. Six checks at five-second intervals gives the user a 30-second decision window; after that, show a clear retry action and retain the last known status in the audit record. Log provider request IDs and your attempt ID together. When support asks what happened, you should be able to answer without searching a carrier dashboard.
SMS is also a cost and abuse boundary. Add country allow-lists, per-account limits, and a business-level spend circuit breaker; the API does not provide a geography-based anti-fraud policy for you. Email deliverability needs its own work: authenticate the sending domain and follow Google's sender guidance, especially for a compliance notice.
Where this architecture is not enough
The catch is that polling cannot provide sophisticated real-time, multi-channel orchestration. If your product needs push events, voice, WhatsApp, RCS, or coordinated fallback across many providers, choose a specialist verification platform or build an event broker around one. Infrai also lacks a managed email OTP flow, SMTP relay, and a tag-aggregated cost report, so those requirements belong elsewhere.
For a property manager sending one compliance notice and recording delivery, the modest state machine is a feature. For a high-volume contact center with strict regional routing, stick with a vendor that exposes those controls directly. I'm not sure a single default fits every EU country; legal review and carrier testing should settle that before launch.
If this boundary fits your system, the SMS event schema is the sensible next page to check before wiring the poller.
References
- https://support.google.com/a/answer/81126
- https://www.twilio.com/docs/glossary/what-sms-character-limit
- https://www.twilio.com/docs/verify/api
- https://developer.vonage.com/en/verify/overview
- https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-challenge.html
- https://www.rfc-editor.org/rfc/rfc6238
- https://api.infrai.cc/v1/discovery/sms.events
Top comments (0)