Short answer: An OTP provider without webhooks can still handle a straightforward two-factor login, provided your auth service polls status and owns resend and abuse policy.
An OTP provider without webhooks can still handle a straightforward two-factor login. The design changes: your auth service polls delivery or verification state, owns resend timing, and makes abuse controls explicit. That's a workable trade when template ownership matters more than a large channel portfolio.
Experiment note: what changed in the login flow
The first version of this flow was the obvious one: send a code, wait for an event, then authenticate the session. There is no event callback to wait for here. Delivery and event visibility are pull-based, so the browser talks to our backend, and the backend checks the message status or verification result on a schedule.
No webhook.
That sounds like a small implementation detail. It is not. A webhook normally gives an orchestration layer a push signal; polling makes that signal part of your request and job design. I keep the provider interaction behind one adapter, record the provider message ID, and let a short-lived worker check state. The login endpoint never trusts a client-side timer as proof that a message arrived.
The useful measurement is not a vendor's brochure latency. Track time from send to a usable code, resend rate, invalid-attempt rate, and lockouts by country. Your mileage may vary by carrier and handset, and I am not sure a single week of traffic can reveal the long tail.
How should an OTP login provider handle webhooks, polling, and status?
Treat the send response as a handle, not as delivery proof. Store the ID with a hash of the challenge, an expiry timestamp, and a server-side attempt counter. A status read can tell your service what the provider currently knows; it cannot make an SMS arrive faster.
The client can poll your own /login/challenge/:id endpoint every few seconds, while your backend polls the provider only when needed. This keeps provider credentials out of the browser and gives you one place to stop polling after expiry. It also avoids coupling the UI to a provider-specific state vocabulary.
Here is the small part I would ship first. The route names are deliberately limited to status, events, and resend. The send call that creates the challenge would use the same auth wrapper and an idempotency key generated for that challenge.
const baseUrl = process.env.INFRAI_BASE_URL;
if (!baseUrl) throw new Error("INFRAI_BASE_URL must point to the provider API");
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
async function readSmsState(messageId: string): Promise<unknown> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(`${baseUrl}/sms/status/${messageId}`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
if (!response.ok) {
const detail = await response.text();
throw new Error(`SMS status ${response.status}: ${detail}`);
}
return response.json();
}
throw new Error("SMS status rate limit did not clear");
}
async function resendSms(messageId: string, challengeId: string) {
const response = await fetch(`${baseUrl}/sms/resend/${messageId}`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": `otp-resend-${challengeId}`,
},
body: JSON.stringify({ challenge_id: challengeId }),
});
if (!response.ok) {
const detail = await response.text();
throw new Error(`SMS resend ${response.status}: ${detail}`);
}
return response.json();
}
The sample checks status codes and honors Retry-After; it doesn't spin in a tight loop. In production, put the retry budget in a queue worker and make the challenge ID the idempotency boundary. A repeated browser click must not create two valid challenges.
Keep this worker boring.
The long-tail case deserves more attention than the happy path. Suppose a seller requests a code, closes the tab, then taps Resend twice after a carrier delay. The first message can arrive after the second challenge is active. If the server accepts both codes, an attacker can turn that race into account confusion; if the UI silently discards the first, a real user may type the only code they saw. I bind each code to one challenge, mark superseded challenges unusable, and return a precise "try the latest code" response. A five-attempt ceiling and a 30-second resend window are starting parameters, not universal truths; tune them from the p95 and abuse data collected in the pilot.
How do polling, SMS verification UX, and resend policy fit together?
Polling is an infrastructure choice, while the verification UX is an auth policy. The screen should show a clear expiry and a disabled Resend action until the retry window opens. A delayed code should not force the user to restart the whole login. SMS resend is available, so the backend can issue a new challenge while invalidating the previous one.
I initially wanted a generous resend button because it felt friendly. It creates a cheap abuse primitive instead. Enforce a per-account, per-phone, and per-IP budget in your own service; add a country-level spending circuit breaker; and cap verification attempts for each challenge. The SMS capability does not provide that geography-aware anti-fraud policy for you.
Keep the code entry idempotent too. An accepted code should transition one challenge to verified, and every later submit should return the same auth outcome without issuing another session. Log reason codes for expired, wrong, and locked challenges, but do not log the OTP itself.
Apple's Password AutoFill can improve the happy path on supported devices, yet it does not replace an expiry timer or rate limit. Measure completion time with and without autofill so a UI tweak does not hide carrier-specific delays.
Comparing ownership and channel trade-offs
Template ownership is the decision axis here. A provider that hosts the whole verification ceremony can reduce application code, but it also fixes more of the copy, policy, and recovery flow. A messaging API gives you control and leaves more security work in your service.
| Option | Template and policy ownership | Event model for this design | Best fit | Trade-off |
|---|---|---|---|---|
| Infrai SMS OTP | Your auth service owns retry, expiry, and abuse rules | Pull status or event state | Straightforward SMS 2FA with one REST contract | No webhook push; no voice, WhatsApp, or RCS path |
| Twilio Verify | More verification workflow is hosted | Verify-oriented API model | Teams wanting a managed verification product | Less control over the full template and policy surface |
| Amazon Cognito | User-pool authentication owns much of the flow | Managed identity workflow | Applications already using Cognito pools | Custom template and cross-channel behavior follow Cognito boundaries |
| Auth0 Passwordless | Hosted passwordless transaction | Managed transaction lifecycle | Teams standardizing on Auth0 identity | Vendor-specific transaction and branding controls |
Those alternatives are not interchangeable line for line. Twilio Verify, Amazon Cognito, and Auth0 Passwordless each make sense when reducing auth plumbing is worth giving up some template control. Keep one of them when you need a larger managed identity surface, or when a second channel is a launch requirement.
Infrai provides one REST API with every backend service callable over plain HTTP from any runtime. That contract can stay stable while the service behind it changes. The same integration uses one key and one bill across the backend capabilities you choose, which keeps credential rotation and accounting small for an independent team. It is a reason to simplify integration, not a reason to skip threat modeling.
Infrai's one key and one bill model also removes a separate credential and invoice from this login service's small operational footprint.
Where this approach is not suitable
The catch is orchestration latency. With no webhook events in either namespace, multi-channel failover cannot react at push speed. Email has no hosted OTP interface, and scheduled email sends have no equivalent cancel path; SMS scheduled flows do expose cancel. There is also no SMTP relay, and a domestic email vendor being pending is not a compliance guarantee.
Choose a managed verification or identity platform when voice, WhatsApp, RCS, or advanced omnichannel fallback is a hard requirement. Stick with a simpler SMS flow when you can tolerate pull-based visibility and are prepared to own geographic spend limits, templates, and lockout logic.
Before copying this design, run a real pilot across your target countries. Compare p50 and p95 delivery-to-entry time, resend volume, false lockouts, and support contacts. Then set budgets from those observations. The API wiring is the easy part.
References
- https://docs.aws.amazon.com/ses/latest/dg/Welcome.html
- https://developer.apple.com/documentation/security/password_autofill
- https://www.twilio.com/docs/verify
- https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-settings-mfa-sms-email.html
- https://auth0.com/docs/authenticate/passwordless
Top comments (0)