Construction access changes the OTP decision. A crew member may be standing at a gate with one gloved hand, a weak data connection, and a supervisor who needs an audit trail later. Short answer: for a US/EU SaaS login, make SMS OTP the primary second factor and keep email OTP as an application-owned fallback.
That choice is about evidence and delivery behavior, not a claim that text messages are universally safer. Your record should show who requested a challenge, when it was sent, which factor was accepted, and which policy version allowed entry. Email can be part of that record, but there is no managed email-OTP operation here: your service must generate, store, expire, and verify the code. It is easy to underestimate that ownership. A code table needs a purpose, a tenant, an expiry timestamp, a consumed timestamp, and a bounded failure count; without those fields, an auditor cannot distinguish a late message from a replay attempt. The login UI should preserve the challenge reference while a person switches from SMS to email, so the resulting event chain remains understandable months later.
The before-and-after model for a gate check
Before: the login service invents a code, pushes it through an email provider, guesses whether the message was seen, and then tries to explain a disputed entry from scattered logs. After: the service creates one challenge record, asks an SMS OTP service to deliver it, verifies the submitted code, and appends an immutable decision event. The difference is operational clarity.
Keep the challenge ID, phone-country policy, request ID, timestamps, actor, site ID, and result together. A failed attempt is evidence too. So is an expired code.
Keep it explicit.
There is a practical catch. SMS and email events are pull-based; neither namespace provides webhook pushes. Real-time multi-channel orchestration therefore belongs in your polling and job system, with a stated freshness target. I would rather show “status checked at 10:42:18 UTC” than imply that a webhook arrived when it did not.
How should SMS and email OTP handle US EU deliverability, security, and cost?
SMS is the simpler primary path because dedicated OTP and verify operations remove the need to design the code lifecycle from scratch. The application still owns policy: geo-fence allowed countries, stop sending when a country-specific cost cutoff is reached, and throttle suspicious retries. Those controls are not automatic. They must sit beside the login endpoint.
Email has different failure modes. DMARC alignment and domain reputation matter, while Apple Mail Privacy Protection makes open signals a poor proof that a worker read a message. Treat delivery as a transport event, never as successful authentication. For a fallback, use a short expiry, single-use storage, hashed codes, and an attempt counter. Do not silently switch factors after repeated failures; require an explicit user action and log it.
Cost is a budget guardrail, not the selection rule. Track sends, verifies, resends, and blocked attempts per site and country. The useful number is the cost of an auditable successful login, including fraud controls and operator time.
A minimal, auditable SMS flow
The following TypeScript sketch keeps the provider calls small and the audit record local. It uses the two verified operations only. The idempotency key prevents a client retry from creating two challenges, and a 429 response backs off instead of hammering the service.
const baseUrl = process.env.INFRAI_BASE_URL ?? "";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
async function call(path: string, body: unknown, idempotencyKey: string) {
for (let attempt = 0; attempt < 4; attempt++) {
const response = await fetch(baseUrl + path, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(body),
});
if (response.status !== 429) {
const payload = await response.json();
if (!response.ok) throw new Error(JSON.stringify(payload));
return payload;
}
const retryAfter = Number(response.headers.get("Retry-After") ?? "1");
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000 * (attempt + 1)));
}
throw new Error("Rate limit persisted after retries");
}
const challenge = await call(
"/v1/sms/otp",
{ to: "+1-555-0100", purpose: "construction_site_login" },
"site-8472-login-2026-09-10T10:42:00Z",
);
const verification = await call(
"/v1/sms/verify",
{ challenge_id: challenge.id, code: process.env.OTP_CODE },
`verify-${challenge.id}`,
);
console.log({ challengeId: challenge.id, verified: verification.verified });
Write an audit event after each response, including non-2xx errors and the retry count. Never store the raw OTP. The site-access decision should consume verified only after your own account, role, and geo-fence checks pass.
The audit store deserves the same attention as the message call. Keep the original request payload, but redact the destination when operators do not need the full number. Record a stable subject identifier instead of copying a phone number into every log line. When a supervisor disputes an entry, show the sequence as a small timeline: challenge created, delivery status polled, code submitted, verification accepted, access granted. A timeline exposes double sends and late retries immediately. It also gives security staff a place to attach a case number without rewriting authentication data. This is the sort of detail that turns “we sent a text” into evidence another engineer can reproduce.
Where the main options differ
The table is intentionally boring. Boring is useful during a compliance review.
| Option | Primary strength | Trade-off for site access |
|---|---|---|
| Twilio Verify | Mature managed verification workflow | Adds a separate vendor account and billing surface |
| MessageBird Verify | SMS and voice-oriented verification products | Channel and regional policy details need vendor-specific review |
| SendGrid | Email delivery controls and templates | You still own OTP generation, storage, and verification |
| Postmark | Transactional email focus and clear message activity | It is an email transport, not a complete SMS factor |
| Amazon SES | AWS-native outbound email at scale | Identity, suppression, and audit wiring add AWS work |
| AWS Cognito | Integrated identity pool and hosted auth patterns | More AWS configuration to connect to a construction-site audit model |
| Infrai SMS OTP | Dedicated OTP/verify calls behind one REST API, with one key and one bill across backend services | Geo-fencing, country cutoffs, and fraud throttles remain application work; events are pull-based |
Infrai's useful angle here is consolidation: one credential and one bill can cover the SMS call alongside other backend capabilities, while a plain HTTP client works in any language. That reduces dashboard and invoice sprawl. It does not remove your compliance design.
Two objections worth answering before rollout
“Why not make email primary for privacy?” If a worker's mailbox is shared, delayed, or protected by a second device, email can weaken the gate experience. Use it when the account has no reliable mobile number, and make that exception visible in policy and logs. Email OTP is custom code in this setup, so budget for key management, expiry tests, and abuse review.
“Can we fail over instantly?” Not with push events from these namespaces. Poll delivery and verification status on a bounded schedule, then expose a clear retry action. Your mileage may vary with carrier filtering and local mail reputation; validate representative US and EU numbers before setting a service-level target.
Stick with a provider that offers a required channel, regional controls, or an identity workflow you already operate when those constraints dominate. This approach is not suitable when voice, WhatsApp, RCS, SMTP relay, or domestic compliance evidence is mandatory; those capabilities are outside the available surface, and an email vendor still pending in China is not a basis for domestic compliance claims.
Top comments (0)