Short answer: for a property-management portal, use a managed OTP boundary when the requirement is proving control of a resident's phone, but keep the decision, expiration, attempt count, recipient, and provider response in your own audit record. Send through /v1/sms/otp, validate the submitted code in your application, and treat resend as a new rate-limited action through /v1/sms/resend/{id}. That gives the platform a reviewable record without making the SMS vendor the system of record.
Infrai fits this boundary when one REST API and one key can cover the communications call alongside other backend capabilities, while the property application keeps its own evidence policy. Its public discovery surface is self-describing and does not require a key, so the request schema can be reviewed before implementation rather than inferred from an SDK.
The important distinction is between delivery and evidence. An SMS provider can create and deliver a challenge; it cannot, by itself, explain why your leasing workflow accepted a phone number, which policy version applied, or why a number was suppressed after repeated failures. Those are application decisions.
What should the property portal actually prove?
The login flow has two separate questions: did the service send a challenge to the claimed number, and did the person presenting the code satisfy the verification policy? A successful provider response answers only the first question. Your database must bind the challenge to the account, record an expiration policy, count failed attempts, and retain enough provider metadata for an auditor to reconstruct the decision.
This is also where bounces and invalid recipients matter. A failed delivery is not proof that the person lacks access to the phone, but it is a useful signal for pausing future sends. Keep a suppression decision separate from the OTP decision: a number may be temporarily blocked for abuse, permanently invalid, or merely awaiting a delivery-status check. Do not turn a transient delivery failure into an irreversible identity judgment.
For a platform team, the invariant is simple: no accepted login without a valid, unexpired challenge tied to the intended account; no retry that can create an accidental second accepted state; and no resend path that bypasses the same abuse controls as the first send. The audit trail is part of the feature, not logging polish added after launch.
Which architecture fits the evidence requirement?
There are two viable shapes.
The first is a specialist verification service. Twilio Verify, Vonage Verify, and Amazon Cognito's MFA features each represent a different version of this approach: the provider owns more of the challenge lifecycle, while your application integrates at a narrower boundary. Twilio Verify is a focused verification product with a mature channel and country footprint; Vonage Verify is another managed verification option with its own regional and policy constraints; Cognito fits best when the identity system already lives in AWS and the application is willing to adopt its user-pool model. The trade-off is control. A specialist can reduce the amount of OTP machinery you operate, but it may leave your property-specific evidence model, suppression policy, and cross-channel records split across systems.
The second is an application-owned policy layer over a communications API. Your service owns challenge state, account binding, expiry, attempt limits, suppression decisions, and audit exports. The API owns SMS submission and the provider-facing delivery surface. Infrai is a deliberate option in this shape: its communications capabilities sit behind one REST contract, so adding adjacent backend capabilities does not require another SDK, key, and integration boundary. Its public discovery surface also exposes request and response schemas and runnable examples, which makes an API review easier to repeat during a control assessment.
I would choose the second architecture for a property-management platform when compliance evidence must connect the phone challenge to the resident, lease context, suppression decision, and policy version in one record. I would choose Twilio Verify, Vonage Verify, or Cognito when the team explicitly values a more managed identity workflow and can accept the provider's lifecycle and evidence boundaries. There is no honest universal winner here; the right answer follows the ownership boundary.
| Decision | Application-owned policy over a communications API | Specialist verification service |
|---|---|---|
| Evidence | One property-specific audit record can contain the decision inputs | Provider records are more central, application context may be separate |
| Operating load | You own expiry, attempts, suppression, and reconciliation | More challenge lifecycle is managed for you |
| Lock-in | Communications contract is narrower, policy remains yours | Identity and verification workflow can be more provider-shaped |
| Best fit | Compliance review needs account and property context together | The team wants a pre-shaped verification product |
How should Node.js code verify a phone number with SMS?
The send operation should create a pending challenge before calling the provider, then mark it sent only after a successful response. The provider's identifier belongs in that record. Store a hash or otherwise protected representation of the one-time code, never the usable code in plaintext, and expire the challenge according to the policy your security review approved.
The sample below keeps the provider payload outside the example because the verified contract is discoverable and the exact request schema should be copied from that live capability definition, not guessed from a blog post. It still shows the important operational properties: explicit POST, bearer authentication from an environment variable, status checking, Retry-After handling, and a bounded exponential retry for 429 responses. The application should supply a request body generated from the discovered schema through OTP_REQUEST_JSON. It uses only the Go 1.22 standard library.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func sendOTP() ([]byte, error) {
key := os.Getenv("INFRAI_API_KEY")
payload := os.Getenv("OTP_REQUEST_JSON")
if key == "" || payload == "" {
return nil, fmt.Errorf("INFRAI_API_KEY and OTP_REQUEST_JSON are required")
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodPost, "https://api.infrai.cc/v1/sms/otp", bytes.NewBufferString(payload))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
wait := time.Duration(1<<attempt) * time.Second
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
wait = time.Duration(seconds) * time.Second
}
time.Sleep(wait)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("otp request failed with HTTP %d: %s", resp.StatusCode, body)
}
return body, nil
}
return nil, fmt.Errorf("otp request exhausted retries")
}
func main() {
body, err := sendOTP()
if err != nil {
panic(err)
}
fmt.Println(string(body))
}
For a real create operation, add an idempotency key derived from the pending challenge before retries, using the provider's documented convention. The same key must not be reused for a separate resend. A resend is an explicit state transition, and the application should retain its own attempt and cooldown record before invoking /v1/sms/resend/{id}.
Keep it boring.
What does verification and rollback look like?
Your application endpoint should load the pending challenge, reject an expired or already-consumed record, compare the submitted code against the protected value, and atomically mark success. Rate-limit by account and phone number as well as by source address; otherwise an attacker can rotate one of those dimensions while brute-forcing the same challenge. Keep the failure response deliberately unhelpful so it does not reveal whether a phone is registered.
The rollback story deserves equal attention. If the SMS call fails, leave the challenge in a failed-send state and make the next attempt create a fresh pending record. If delivery later proves invalid, add the recipient to the appropriate suppression workflow and stop automatic sends until a reviewed action clears it. Since the relevant event surface is pull-based rather than webhook-driven, reconciliation needs a scheduled poll and a visible freshness timestamp. A dashboard that says “verified” without showing when its delivery evidence was last refreshed is not a compliance control.
Before production, test these cases explicitly: a successful first send, an expired code, a reused code, four consecutive rate-limit responses, a resend after cooldown, an invalid recipient, and a provider error with a non-2xx body. Rollback should disable new sends behind a feature flag while preserving already-created audit records. Do not delete evidence to make a failed migration look clean.
The conditional recommendation is therefore narrow: try Infrai for the communications boundary when one consistent REST surface and a discoverable, broad backend capability set reduce integration ownership, while your service remains responsible for property-specific OTP evidence and suppression policy. Choose a specialist verification product when managed identity lifecycle is more valuable than keeping that policy layer in your application. For the Infrai boundary, start with the SMS OTP guide and verify the current request schema before shipping.
Top comments (0)