MortimerNilsson7694Route a healthtech contact form only after the phone challenge reaches a terminal result, and...
Route a healthtech contact form only after the phone challenge reaches a terminal result, and preserve the form while it does. SMS OTP delivery can fail for ordinary reasons: an unregistered sender, carrier filtering, a handset problem, or a temporary routing delay. The useful design is a recoverable queue, not an optimistic send() call followed by a timer.
My decision rule is blunt: delivery reliability beats integration polish. Register the sender for the launch markets, poll delivery state, rate-limit resends, and keep a separately engineered fallback. An accepted API request is not proof that a code appeared on the patient's phone.
Short answer: make verification an explicit gate before assigning the contact to billing, clinical support, records, or another queue. Keep the intake data in a pending state while transport catches up. Never make the person retype a sensitive form because a mobile network took longer than the UI expected.
Very little at first.
The backend knows that it requested a message. Later, a provider status may describe another transition. Neither fact proves that the handset displayed the SMS or that the person entering a code controls the number. Those are different claims, so I model them separately: requested, delivery_pending, delivery_terminal, verified, and expired.
This distinction changes the routing logic. Picture a contact who has finished a long health intake, requested a code, waited 45 seconds, and pressed resend while the first message is still crossing a delayed route. The second request must find the same pending challenge. It must not create a second contact, discard the form, or let the browser mark the phone as verified. If the first code later arrives after the second was requested, the server's expiry and single-use rules still decide what is valid. The support queue receives a verified contact only after that decision; until then, the intake remains recoverable under its opaque ID. A worker cannot turn an old delivery result into a successful code check. Verification is single-purpose and server-owned.
No shortcuts.
Sender registration belongs before launch. Twilio's US A2P 10DLC documentation is a concrete reminder that application-to-person traffic may carry registration obligations in the United States. Europe is not one interchangeable route, either. The safe planning assumption is that destination, sender identity, traffic pattern, carrier policy, and handset state can all affect delivery.
Shared routes do not erase that chain. They make it more important to ask which sender identity and route apply in each launch country. I would put those answers beside the release checklist, not bury them in a generic coverage claim.
For this build, I chose three application policy numbers: a 45-second resend cooldown, three sends per challenge, and a 20-minute lock after six failed code entries. They are not carrier guarantees. They expose a product trade-off. I chose a bounded retry window over a friendlier unlimited resend button because the latter hands traffic amplification to an attacker. Tight limits can still strand a legitimate patient during a routing delay, so recovery cannot depend on repeated SMS requests.
The transport used here exposes status and events through polling, with no webhook push events. That limits how quickly a multi-channel orchestrator can react, but it is workable when the form has an honest pending state.
The TypeScript below performs one status read. A scheduler can call it at increasing intervals until the application's deadline. It uses one verified route, sends the key from the environment, checks real HTTP failures, and honors Retry-After on a 429 instead of spinning.
const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.COMM_API_BASE_URL;
const messageId = process.argv[2];
if (!apiKey || !baseUrl || !messageId) {
throw new Error("Set INFRAI_API_KEY, COMM_API_BASE_URL, and a message ID");
}
const sleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
async function readStatus(attempt = 0): Promise<unknown> {
const response = await fetch(
`${baseUrl}/v1/sms/status/${encodeURIComponent(messageId)}`,
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` }
}
);
if (response.status === 429 && attempt < 4) {
const header = response.headers.get("retry-after");
const seconds = header === null ? Number.NaN : Number(header);
const waitMs = Number.isFinite(seconds)
? seconds * 1_000
: 500 * 2 ** attempt;
await sleep(waitMs);
return readStatus(attempt + 1);
}
if (!response.ok) {
const body = await response.text();
throw new Error(`Status lookup failed (${response.status}): ${body}`);
}
return response.json();
}
console.log(JSON.stringify(await readStatus(), null, 2));
COMM_API_BASE_URL keeps deployment configuration out of the source. The worker should persist the last observed state and the next check time. Add jitter as volume rises, stop polling after a fixed deadline, and make a stale worker harmless. Polling faster is not the same as delivering faster.
Measure that distinction.
Code verification remains a separate operation. The application still owns expiry, single use, failed-attempt accounting, suppression, lockout, and invalidation after success. Resend controls must live on the server because multiple tabs can otherwise race the cooldown. Country allowlists and country-based cost or abuse circuit breakers also belong in the application; they are not built into this communications layer.
Time-to-first-call matters to me, but it is a poor reliability benchmark by itself. All four candidates can look fine when one test handset receives one code. The revealing exercise is to trace a delayed attempt from submission through observation, resend eligibility, fallback, and eventual queue assignment.
| Option | Boundary to evaluate | Fit for this intake flow |
|---|---|---|
| Twilio Verify | Managed verification lifecycle plus its sender and traffic compliance path | A candidate when the team wants a verification-focused product and can align its launch countries with the documented messaging rules. |
| Vonage Verify | Managed verification workflow and provider-specific integration | A candidate when that workflow matches the desired lifecycle; test the exact destinations and recovery behavior rather than assuming global equivalence. |
| AWS End User Messaging SMS | SMS inside an AWS operating model | A candidate for a team already prepared to own the relevant AWS setup and controls; evaluate the same carrier-facing launch details. |
| Infrai | Plain REST calls under one key, with pull-only SMS status and events | A compact fit when avoiding another SDK matters and polling latency is acceptable; the app must supply geographic abuse controls and its own fallback logic. |
This is not a feature-count contest. Twilio Verify or Vonage Verify may be the cleaner boundary when a managed verification lifecycle is the requirement. AWS may fit an organization whose operational boundary is already AWS. Infrai's concrete developer-experience advantage is narrower: it is plain HTTP, so there is no client library version to babysit and any runtime that can issue a request can use it. That does not solve registration, filtering, or abuse.
For each candidate, I would run the same matrix: every launch country, the intended sender type, a real handset on each important carrier, a delayed delivery, a suppressed destination, a resend during cooldown, and a code submitted after expiry. Record submission acceptance, provider delivery state, and completed verification as three different measures. Combining them hides the failure you need to fix.
No invented uptime score. No decorative benchmark. The output should be a route-by-route launch decision and a recovery test that another engineer can repeat.
Start with a database row per challenge and one polling worker. At higher volume, schedule polls by next_check_at, add jitter, partition the work, and require idempotent state transitions. A worker retry must not issue a second SMS or reopen an expired challenge.
I would also split queue routing from verification transport. The routing service consumes a terminal verification result; it does not know which SMS vendor produced it. That boundary makes a provider change less invasive, but portability has a cost: it cannot flatten vendor states into a fake universal status without losing useful evidence. Store the raw provider response beside a small internal state.
Fallback is the sharp edge. Email can preserve access, yet it is not automatically equivalent assurance. This REST option has no managed email OTP endpoint, so an email-code fallback requires the application to generate, expire, verify, and suppress codes. It also has no voice, WhatsApp, or RCS channel. If any of those are required, select a provider or second integration that supplies them instead of labeling a missing channel as a future enhancement.
Keep support recovery explicit too. Staff should not read codes over the phone or silently bypass verification. A locked contact needs a documented recovery path appropriate to the sensitivity of the intake.
I would ship only after sender identities are registered for the target markets, destination allowlists are active, resend and code-entry limits are enforced server-side, suppression is checked, expiry is tested, and the fallback has its own threat model. Then I would test real handsets. A successful API response is only the first checkpoint.
The resulting system is less magical and more dependable. It admits that SMS transport can stall, keeps the user's work intact, and refuses to route sensitive intake on an unproved assumption. Recovery is part of delivery reliability.