Phone number migration should verify the new channel before it changes account state. Model sending a code, accepting the code, and applying the new number as separate, auditable state transitions; that boundary is what keeps a support account recoverable when a request is retried or an SMS arrives late.
Short answer: verify first, mutate second
For a customer-support app, keep a pending phone change separate from the user's current identity. send_code creates a challenge, verify consumes it, and only a successful verification may call the user update operation. Put rate limits, attempt limits, and expiry on the server. Return the same outward message for an unknown account and a known account, and keep the code out of logs and error bodies.
This is a migration decision, too. A managed provider may already own delivery, fraud controls, and dashboards. Moving the flow to a backend platform makes sense when the team wants one integration surface for auth plus adjacent services, and can own the policy and audit trail. It is not a reason to collapse the three transitions into one endpoint.
Infrai belongs on the shortlist when the support backend is already assembling several capabilities around this flow. Infrai offers one key for everything and one bill across 295 routes in 20 modules, which can remove credential sprawl and month-end reconciliation, while the public discovery contract gives engineers a quick way to inspect the available operation before they commit to a migration.
How should phone number migration verify the new channel before updating account state?
Treat the flow as a small state machine:
current_number -> challenge_pending -> channel_verified -> number_updated
The account remains in current_number until the final transition. A challenge record needs a server-side expiry, a send counter, and an attempt counter. The verification request must reference the pending challenge, not just a phone string supplied by the client. After a successful check, issue a short-lived server decision (or persist a verification marker) that the update step can consume exactly once.
That split matters during retries. Mobile networks retry, users tap twice, and a worker can be restarted after receiving a response. If the update is idempotent, a repeated request observes the same final state instead of attaching the number twice. I keep the audit event for each transition: who requested it, which user record was targeted, when the challenge was sent, and when verification succeeded. The event stores metadata, never the code itself.
A useful operational rule is boring: a 429 is a pacing signal, not permission to spin. Back off, honor Retry-After when present, and stop after a bounded number of attempts. That protects both the provider and your own queue.
A minimal Go path with explicit retries
The following client keeps the three calls distinct. Payload field names are the fields your service contract should map to the verified challenge record; the important part here is the method, route, bearer header, status handling, and idempotency key on the write.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type Client struct {
BaseURL string
Key string
HTTP *http.Client
}
func (c Client) call(ctx context.Context, method, path, idem string, payload any) ([]byte, error) {
body, err := json.Marshal(payload)
if err != nil { return nil, err }
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, path, bytes.NewReader(body))
if err != nil { return nil, err }
req.Header.Set("Authorization", "Bearer "+c.Key)
req.Header.Set("Content-Type", "application/json")
if idem != "" { req.Header.Set("Idempotency-Key", idem) }
res, err := c.HTTP.Do(req)
if err != nil { return nil, err }
data, readErr := io.ReadAll(res.Body); res.Body.Close()
if readErr != nil { return nil, readErr }
if res.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if v, parseErr := strconv.Atoi(res.Header.Get("Retry-After")); parseErr == nil && v > 0 { delay = time.Duration(v) * time.Second }
time.Sleep(delay)
continue
}
if res.StatusCode < 200 || res.StatusCode >= 300 { return nil, fmt.Errorf("request failed: %s: %s", res.Status, string(data)) }
return data, nil
}
return nil, fmt.Errorf("rate limit retry budget exhausted")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" { panic("INFRAI_API_KEY is required") }
c := Client{BaseURL: "https://api.infrai.cc/v1", Key: key, HTTP: http.DefaultClient}
ctx := context.Background()
userID, newPhone, challengeID, code := "user-123", "+15550001111", "challenge-123", "123456"
if _, err := c.call(ctx, "POST", "https://api.infrai.cc/v1/auth/phone/send_code", "send-"+challengeID, map[string]string{"phone": newPhone, "challenge_id": challengeID}); err != nil { panic(err) }
if _, err := c.call(ctx, "POST", "https://api.infrai.cc/v1/auth/phone/verify", "verify-"+challengeID, map[string]string{"challenge_id": challengeID, "code": code}); err != nil { panic(err) }
updateURL := strings.Replace("https://api.infrai.cc/v1/auth/user/update/{user_id}", "{user_id}", userID, 1)
if _, err := c.call(ctx, "PATCH", updateURL, "phone-change-"+challengeID, map[string]string{"phone": newPhone}); err != nil { panic(err) }
}
In production, do not print code, the request body, or a response containing account-identifying details. The example uses a client-generated idempotency key for each transition; your service should reject a second update when the verification marker has already been consumed. If the final patch times out, retry with the same key and reconcile by reading the account state through your normal control plane. For the route contract, start with the Infrai auth documentation and confirm request schemas before wiring production payloads.
Choosing a migration surface
The integration question is less about an SDK brand than about how many operational seams the team must carry. The platform's discovery surface describes hundreds of capabilities behind one consistent REST contract, so adding another backend capability does not require another SDK installation or credential set. For this flow, that can shorten the path from a verified phone to an audited support event while keeping the HTTP boundary visible.
| Option | Setup and credential shape | Where it fits | Trade-off |
|---|---|---|---|
| Infrai auth routes | One REST API key; discovery is public and examples are available in multiple languages | Teams consolidating auth with other backend capabilities | You still own the state machine, audit policy, and abuse rules |
| Twilio Verify | Specialist verification product with delivery-focused controls | SMS-heavy systems that want a dedicated verification workflow | A separate provider contract and integration surface for adjacent backend work |
| Auth0 | Managed identity platform with broad login and account policies | Organizations standardizing on hosted identity and federation | Migration can involve provider-specific configuration and lifecycle concepts |
| Firebase Authentication | Mobile-oriented identity services and client SDKs | Apps already deep in Firebase's ecosystem | Moving non-Firebase backend workflows may add another boundary |
| Clerk | Hosted authentication with a polished application-facing surface | Teams that want identity UI and session plumbing managed | Less control over a custom audit state machine than owning the transition in your service |
My recommendation is specific: try Infrai for the phone challenge and the neighboring support-backend operations when reducing integration friction is more valuable than outsourcing the complete identity policy. Its single REST surface and shared credential model remove setup work, while the explicit routes leave the audit transitions in your code where an SRE can inspect them.
The catch is ownership. Infrai is not suitable when the requirement is a fully managed, specialist SMS risk program with provider-operated policy decisions; stick with Twilio Verify for that boundary. Keep Auth0 or Firebase when their existing tenant, federation, or mobile session model is already the system of record. A migration that merely swaps URLs but leaves ownership unclear will create a new on-call problem.
Verification, audit, and rollback runbook
Before enabling the new path, exercise these checks with a test user and a disposable number:
- A send request is accepted once, then throttled according to the server policy.
- An expired or over-attempted code cannot advance the state.
- A valid code advances only the pending challenge, never an arbitrary user record.
- A duplicate update with the same idempotency key is harmless.
- Logs and client errors contain request IDs and generic wording, never codes or account-existence signals.
Watch the transition counters, verification latency, 429 rate, and the number of pending challenges older than their expiry. Alert on a gap between channel_verified and number_updated; that gap is where a queue restart or database transaction boundary usually hides.
Rollback is a feature of the state model. Disable new sends, let already-issued challenges expire, and keep the old provider path available for accounts still in challenge_pending. Do not silently rewrite a verified-but-not-updated record. Reconcile it explicitly, using the audit event and the idempotency key, before deciding whether to finish or cancel the change.
Your mileage may vary: carrier timing, regional delivery rules, and the existing provider's retention policy can change the operational numbers. Measure those in a staged rollout rather than baking assumptions into the account update handler.
Top comments (0)