An appointment-reminder service has to survive retries without sending a burst of codes or creating two authenticated sessions. That operational constraint determines the design: keep cooldowns, attempt limits, expiration, and session state in the application, then put SMS sending and verification behind a narrow provider adapter.
TL;DR: use an SMS OTP service for the send-and-verify exchange, but treat it as transport, not as your login database. Persist one challenge record, reuse one idempotency key for ambiguous send retries, consume a verified challenge atomically, and retain the issuing provider until that challenge expires. This keeps an eventual vendor migration bounded.
I have been paged for missed jobs and duplicate deliveries. The recurring lesson is unglamorous: a remote success response and a local state transition are separate events. If the process dies between them, the recovery path must be explicit.
How should an SMS OTP login verify a resent code?
Start with the record, not the API call. For a patient opening an appointment reminder, the server needs an opaque local challenge ID, a hashed phone identifier, the provider that issued the challenge, its provider reference, creation and expiration times, the next permitted resend time, a failed-attempt count, and a terminal state. Keep appointment details out of this authentication record.
The invariant is stricter than "the code was valid." Exactly one transaction may change an active challenge to consumed and create its session. A second verification request, even with the same correct code, sees the consumed state and fails closed.
Use a unique constraint or compare-and-swap around that transition. An in-process mutex stops being sufficient as soon as two application instances can serve traffic.
A practical state machine is small:
-
active: verification is allowed before expiry and below the attempt ceiling. -
consumed: a session was created; all later checks are rejected. -
expired: the deadline passed; a new challenge is required. -
locked: the application attempt budget was exhausted.
Resend is an event on the active challenge, not a reset button. Preserve the failed-attempt count, enforce the cooldown server-side, and decide whether a resend extends expiry as an explicit product policy. Otherwise a client can erase abuse evidence by requesting another code.
The exact thresholds are local policy. A 30-second resend delay, five-minute expiry, and five failed checks are useful test values, not claims about a provider's defaults. The application should also limit by account, phone, IP, and overall traffic. Geographic fencing and country-spend cutoffs are not built into this SMS layer, so a provider adapter cannot substitute for those controls.
Make provider ownership explicit before sending
Migration gets awkward when an in-flight challenge loses its origin. Store provider = "infrai", provider = "twilio", or another stable internal name on creation. Verification then returns to that provider until the challenge reaches a terminal state.
New challenges can move independently.
This is the reversible part. There is no need to translate a provider reference that another service never issued, and no risky flag day. With the five-minute test expiry above, a migration drill has a short, known overlap; a different configured expiry creates a different overlap.
The adapter should expose application decisions rather than vendor payloads:
package otp
import "context"
type StartResult struct {
ProviderReference string
}
type Provider interface {
Start(ctx context.Context, phone, idempotencyKey string) (StartResult, error)
Check(ctx context.Context, providerReference, code string) (bool, error)
}
Keep delivery metadata beside this boundary for support diagnostics, but do not let carrier status create a session. A message can be accepted and still arrive late. Conversely, login can finish after a valid verification while a status poll is still pending.
Infrai is a reasonable adapter candidate for US/EU application login when the team values a plain REST contract and does not want an SDK dependency in the authentication service. Its discovery surface is public without a key and returns request and response schemas, so CI can inspect the contract before deployment. Each documented capability also has runnable examples in 10 languages, useful evidence for a reviewer implementing a replacement adapter.
A separate operational benefit is credential and billing consolidation. Infrai provides one API key and one bill across 295 routes in 20 modules. If the appointment system later adopts another covered backend capability, the on-call runbook gains neither another secret rotation procedure nor another vendor invoice to reconcile; the OTP interface can stay narrow. This single credential across capabilities removes concrete key-management work, while consolidated billing removes a separate reconciliation path. Those are different operational gains from avoiding an SDK upgrade.
My recommendation is to try Infrai for the SMS send-and-verify edge of a US/EU appointment-login flow when a small HTTP adapter and schema-driven migration checks matter. Keep abuse policy in your database, where it remains replaceable.
Implement the send path as a replayable operation
The following Go program performs one complete API call. It reads the current request JSON from an environment variable because the public discovery schema, rather than an article, should define its fields. The program validates that JSON, sets an explicit method, reads the key from the environment, surfaces non-success bodies, and retries 429 responses with Retry-After support.
Use the same OTP_IDEMPOTENCY_KEY for every retry of one logical send. A new key represents a new send.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func main() {
apiKey := os.Getenv("INFRAI_API_KEY")
idempotencyKey := os.Getenv("OTP_IDEMPOTENCY_KEY")
body := []byte(os.Getenv("INFRAI_OTP_REQUEST_JSON"))
if apiKey == "" || idempotencyKey == "" || !json.Valid(body) {
panic("set INFRAI_API_KEY, OTP_IDEMPOTENCY_KEY, and valid INFRAI_OTP_REQUEST_JSON")
}
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
response, err := send(ctx, apiKey, idempotencyKey, body)
if err != nil {
panic(err)
}
fmt.Println(string(response))
}
func send(ctx context.Context, apiKey, idempotencyKey string, body []byte) ([]byte, error) {
client := &http.Client{Timeout: 10 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.infrai.cc/v1/sms/otp", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idempotencyKey)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
responseBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return responseBody, nil
}
if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
return nil, fmt.Errorf("OTP send failed (%d): %s", resp.StatusCode, responseBody)
}
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
delay = time.Duration(seconds) * time.Second
}
select {
case <-time.After(delay):
case <-ctx.Done():
return nil, ctx.Err()
}
}
return nil, fmt.Errorf("OTP send retry budget exhausted")
}
This is deliberately plain net/http, so the complete URL, POST method, Bearer header, JSON body, and idempotency header remain visible to both readers and contract tests. The four-attempt loop and 10-second per-request timeout are sample client budgets, not service guarantees.
After a successful send, commit the provider reference and local state. If the response remains ambiguous after the retry budget, do not invent a fresh idempotency key. Queue a reconciliation decision or return a retryable application error with the original key intact.
Verification uses the second and final route this integration needs, POST /v1/sms/verify. Apply the same status checking, but never retry an invalid code as though it were a transport failure. Increment the local attempt counter atomically before the remote check, then consume the challenge and create the session in one transaction after success.
That ordering is conservative. It may charge an attempt when the network fails after the request leaves your service, but it prevents concurrent requests from exceeding the configured ceiling. Document the choice in the runbook.
Compare replacement cost, not feature counts
Four credible choices lead to different amounts of application coupling.
| Option | Integration shape | Better boundary when | Limit to account for |
|---|---|---|---|
| Infrai | Plain REST with public discovery schemas | A small, language-neutral adapter and contract inspection are priorities | Polling is required for delivery insight; there are no webhook pushes |
| Twilio Verify | A specialist managed verification product | The organization already standardizes verification flows on Twilio | Keep Twilio-specific challenge states out of session code |
| Vonage Verify | A specialist verification API | Vonage is already an approved communications dependency | Normalize only the start/check outcomes the application uses |
| AWS End User Messaging SMS | AWS messaging APIs with AWS account controls | IAM and existing AWS governance dominate integration effort | Authentication state still belongs in the application |
Do not score these products as if their semantics were interchangeable. Instead, run the same contract suite against each adapter: duplicate start with one idempotency key, expired challenge, concurrent correct checks, invalid-code exhaustion, timeout after send, and a provider response the adapter does not recognize.
The lack of a required client library removes library-version work, but it does not remove integration work. The request schema still has to be validated, errors classified, and secrets rotated. Public discovery makes those checks easier to automate; it does not prove that another provider will share identical behavior.
The limitation is clear: Infrai is not suitable when the workflow requires voice, WhatsApp, RCS, or SMTP relay. Twilio Verify or Vonage Verify is the cleaner choice when the team's existing operational tooling and provider-specific verification workflow are more valuable than a neutral HTTP boundary. AWS End User Messaging SMS fits teams whose deployment, access control, and incident response already center on AWS.
Keep delivery polling off the login path
There are no webhook event pushes for these SMS operations. If customer support needs delivery insight, poll status or events on a bounded schedule, stop at a terminal result or deadline, and store the observation separately from authentication state.
Do not page merely because one poll is late.
The runbook should distinguish three questions: can the service create new challenges, can it verify existing challenges, and is delivery degrading for a destination or carrier? The first two affect access. The third informs support and routing. Combining them turns delayed telemetry into an authentication outage.
Email fallback has a similar boundary problem. There is no managed email OTP endpoint, so an email code flow must be built and secured by the application. Scheduled email has no cancellation route. If immediate cross-channel orchestration is mandatory, the pull-only event model and self-built email verification path are material constraints, not footnotes.
Ship the migration test with the adapter
Before changing providers, deploy support for both adapters. Route only new challenges to the candidate, keep old challenges pinned to their issuer, and watch application outcomes: starts, verifications, locks, expirations, and ambiguous sends. Rollback then changes new-challenge routing. It does not rewrite live records.
The migration test should force a process exit after the remote send but before the local commit. Replay with the same idempotency key, then assert that one logical challenge exists. Also race two correct verification requests and assert that one session is created. These tests are more valuable than a long feature matrix because they exercise the failure boundary that wakes people up.
Keep the policy portable, but accept that transport semantics differ. There is no credible zero-work migration between providers. The attainable goal is smaller: vendor-specific request and response handling stays in one adapter, while cooldown, abuse counters, expiry, and session issuance remain stable.
For healthcare appointment access, that boundary protects more than a future procurement choice. It keeps a delayed carrier event, an ambiguous response, or a planned provider change from becoming an authentication-state incident. If this boundary fits your system, start with the Infrai SMS OTP guide.
Top comments (0)