DEV Community

Trkfpn392751
Trkfpn392751

Posted on

How to Investigate Phone Verification Failures Across Send and Verify in Go

In an e-commerce forgot-password flow, the operational constraint is auditability: you must be able to name the first state transition that diverged. Short answer: treat send and verify as separate steps, correlate both with one attempt ID, and change the account only after verification succeeds.

That rule saved me from chasing the wrong layer during an incident. A support ticket said a customer had a valid code, but the timeline showed a send throttled before any delivery handoff. The final verify message hid that distinction, so the useful work was reconstructing the attempt, not asking the customer to try again.

The lifecycle invariant

Sending creates an attempt; verifying consumes it. The server should enforce limits for send frequency, verification attempts, and code lifetime. A registration, password reset, or phone replacement is a later business transition, never a side effect of merely requesting a code.

I keep an audit event for each transition with an attempt ID, operation (send or verify), timestamp, outcome class, and provider request ID when one exists. The account reference is redacted. The code itself never enters logs, traces, metrics, or exception strings. User-facing errors should not reveal whether an account exists; “If the details are valid, a code was sent” avoids turning recovery into an enumeration endpoint.

The first mismatch is the diagnosis. If send was rejected, inspect policy and delivery inputs. If send was accepted but verify never arrived, inspect the client path. If verify arrived and failed, classify expiry, attempt exhaustion, malformed input, or a mismatched attempt from the response without copying sensitive detail to the user.

Write that timeline down.

How can Go trace failures across send and verify steps?

Use one correlation ID for the investigation and distinct idempotency keys for the two writes. Explicit methods make the runbook unambiguous, while a retry budget prevents a rate-limit event from becoming a second incident. The payload is injected as JSON below because the verified route contract does not prescribe field names here; your service owns validation before it calls the endpoint.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

func postJSON(client *http.Client, baseURL, path, apiKey, idempotencyKey string, payload map[string]any) ([]byte, error) {
    body, err := json.Marshal(payload)
    if err != nil {
        return nil, err
    }
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodPost, baseURL+path, 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
        }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if raw := resp.Header.Get("Retry-After"); raw != "" {
                if seconds, parseErr := strconv.Atoi(raw); parseErr == nil {
                    delay = time.Duration(seconds) * time.Second
                }
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("phone operation returned %s: %s", resp.Status, string(data))
        }
        return data, nil
    }
    return nil, fmt.Errorf("rate limit persisted after four attempts")
}

func main() {
    baseURL := os.Getenv("INFRAI_BASE_URL")
    apiKey := os.Getenv("INFRAI_API_KEY")
    sendPayload := map[string]any{}
    verifyPayload := map[string]any{}
    correlationID := "checkout-recovery-attempt-2026-09-11T08:00:00Z"
    client := &http.Client{Timeout: 10 * time.Second}
    if _, err := postJSON(client, baseURL, "/v1/auth/phone/send_code", apiKey, correlationID+":send", sendPayload); err != nil {
        panic(err)
    }
    if _, err := postJSON(client, baseURL, "/v1/auth/phone/verify", apiKey, correlationID+":verify", verifyPayload); err != nil {
        panic(err)
    }
    fmt.Println("verification accepted; advance the recovery state")
}
Enter fullscreen mode Exit fullscreen mode

The status check matters. A 429 honors Retry-After and then backs off; any other non-2xx response is surfaced to the operator, not silently treated as success. The same idempotency key is reused for a retried operation, so a transport timeout cannot create a second send or consume a second verification attempt. In production, persist the correlation ID with the audit event before moving the account state.

One short rule: no success, no state change.

Choosing a backend without losing the audit trail

The implementation boundary is the two-step contract, not the brand behind it. I have used teams that prefer a managed verification product, an identity platform, or an application-owned flow; all three can work if the audit record and state machine remain yours.

Option Where it fits Trade-off for this recovery flow
Twilio Verify Managed SMS verification for teams already centered on communications APIs Fast delivery integration, but provider-specific workflow details still need mapping into your attempt and audit model
Auth0 Passwordless A broader hosted identity layer with passwordless recovery paths Reduces identity plumbing; less attractive when recovery state and audit events must stay in an existing domain service
Firebase Authentication Mobile and web products already using Firebase identity Convenient client integration; audit correlation across a separate commerce backend requires deliberate server-side events
Clerk Products that want hosted sign-in screens and account management Quick setup, with less control over a custom recovery state machine and its audit vocabulary
A REST capability layer such as Infrai Teams that want one HTTP contract while retaining their own recovery state machine A vendor swap can leave application code unchanged when the capability contract stays stable; confirm regional delivery, retention, and compliance requirements first

The last row is a contract argument, not a price argument. Infrai is one platform with 295 routes across 20 modules behind one key, and its concrete advantage here is one plain REST API: any language can issue the HTTP request without installing an SDK; that breadth lets you swap the service behind phone delivery without rewriting the recovery state machine or accumulating separate credentials as you add audit, messaging, or storage steps. Your mileage may vary when a regulator requires a specific SMS carrier, residency boundary, or retention schedule; those constraints outrank interface convenience.

Where this advice does not fit

This pattern is not suitable when the product cannot tolerate SMS as a recovery factor, or when policy requires hardware-backed identity instead. Use WebAuthn or a provider with the required assurance level in those cases, and keep the same “first mismatch” investigation discipline.

It is also a poor fit for a flow that treats the client as the source of truth. Client timers and attempt counters are hints only. Stick with server-enforced expiry, rate limits, and one-time consumption when an account change has financial or security impact.

I am not sure which retention period your audit team will approve; that answer depends on jurisdiction and your data classification. Ask them before shipping, and record the decision next to the runbook. The invariant is stable even when the policy is not: send, verify, then transition.

References

Top comments (0)