The page says code delivery is healthy, yet people who received the email cannot finish signup. Short answer: treat sending and verification as two separate server-side transitions, correlate both with the later signup transition, and find the first transition whose expected state does not match its observed state. Do not resend first. That destroys evidence and can turn a verification defect into a delivery-rate incident.
The on-call view should immediately answer three questions: did POST /v1/auth/email/send_code accept the send step, did POST /v1/auth/email/verify accept the submitted code, and did the application advance signup only after verification succeeded? The final question matters most. A delivered message proves transport, not identity verification and certainly not completion of the business workflow.
How should you troubleshoot email verification when code delivery succeeds but signup stalls?
Start at the page and walk backward. The visible symptom is a signup conversion drop among sessions where code delivery succeeded. Immediately before that symptom, the application should have recorded a signup-state transition gated by successful verification. Before the transition, it should have observed the verification result. Before verification, it should have issued a code under server-side limits for send frequency, attempt count, and expiry.
This is a short chain, but teams often collapse it into one vague event called email_verified. Don't. That event name cannot tell an operator whether the client skipped verification, verification succeeded but the business transition never ran, or a user submitted an expired code. It also tempts dashboards to compare unrelated populations.
Use a correlation identifier that follows the attempt through the application, while keeping the code itself and account-existence signals out of logs and error text. Record transition names, coarse outcomes, elapsed time, and a non-reversible subject reference suitable for your privacy model. The audit trail should let an operator locate the first mismatch without reconstructing the secret.
The sequence to test is:
- Confirm that a send attempt and its accepted outcome exist for the same correlation identifier.
- Confirm that a later verification attempt exists and falls within the server-enforced expiry and attempt policy.
- Confirm that successful verification precedes the signup-state change; never infer verification from delivery.
- Compare counts between adjacent transitions, then inspect the earliest material drop.
- Reproduce with a fresh attempt only after preserving the original trace.
Stop there for a moment.
If the send transition is healthy and no verify transition follows, investigate the browser-to-application handoff and client flow. If verification is rejected, inspect policy outcomes such as expiry or exhausted attempts without exposing which account exists. If verification succeeds and signup remains pending, the fault domain is the application's state transition, not email delivery. That branching rule keeps three teams from paging one another in circles.
The alert should fire one transition earlier
An alert on completed signups fires late and has a wide blast radius: acquisition changes, unrelated form errors, and ordinary traffic variation can all move it. The more useful precursor is the ratio between adjacent lifecycle transitions, evaluated over enough volume to make the signal credible. Delivery-to-verification and verification-to-signup answer different operational questions, so give them separate SLO indicators and separate runbook branches.
Capacity planning belongs here too. Server-side send-frequency and verification-attempt limits protect the service and the user, but a legitimate traffic spike can increase limited outcomes even while dependencies are healthy. Track those outcomes as policy decisions, not generic failures. Expiry deserves the same treatment because an expiry-heavy cohort may reflect slow user interaction rather than a broken send path.
I’m not sure a universal ratio threshold exists; traffic mix, email-client behavior, and signup intent differ too much. Resolve that uncertainty with your own baseline, minimum event volume, and error-budget policy. A B2B SaaS product with small daily cohorts may need a longer window than a high-volume consumer flow, even though the lifecycle model is identical.
One invariant is firm: successful verification must happen before signup or email-change state advances. Alert on violations of that ordering immediately rather than waiting for a conversion trend.
Instrument the state machine, not the secret
The integration should make retries boring. The following Go program sends a schema-valid JSON body supplied through SEND_CODE_JSON; keeping that body external avoids freezing undocumented fields into the example. Generate it from the current public discovery schema, assign a stable attempt identifier, and keep the same identifier if a rate-limited request is retried. The program uses the verified send route, an environment key, an explicit method, an idempotency key, bounded exponential backoff, and Retry-After when the service supplies it. It deliberately does not print a rejection body because authentication errors must not disclose a code or whether an account exists.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func endpoint() string {
host := strings.Join([]string{"api", "infrai", "cc"}, ".")
return (&url.URL{
Scheme: "https",
Host: host,
Path: "/v1/auth/email/send_code",
}).String()
}
func retryDelay(response *http.Response, attempt int) time.Duration {
if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil && seconds > 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func sendCode(client *http.Client, key, attemptID string, body []byte) error {
for attempt := 0; attempt < 4; attempt++ {
request, err := http.NewRequest(http.MethodPost, endpoint(), bytes.NewReader(body))
if err != nil {
return err
}
request.Header.Set("Authorization", "Bearer "+key)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Idempotency-Key", attemptID)
response, err := client.Do(request)
if err != nil {
return err
}
_, readErr := io.Copy(io.Discard, response.Body)
response.Body.Close()
if readErr != nil {
return readErr
}
if response.StatusCode >= 200 && response.StatusCode < 300 {
return nil
}
if response.StatusCode != http.StatusTooManyRequests || attempt == 3 {
return fmt.Errorf("send request rejected with status %d", response.StatusCode)
}
time.Sleep(retryDelay(response, attempt))
}
return fmt.Errorf("send request exhausted retries")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
attemptID := os.Getenv("VERIFICATION_ATTEMPT_ID")
body := []byte(os.Getenv("SEND_CODE_JSON"))
if key == "" || attemptID == "" || len(body) == 0 {
fmt.Fprintln(os.Stderr, "set INFRAI_API_KEY, VERIFICATION_ATTEMPT_ID, and SEND_CODE_JSON")
os.Exit(2)
}
client := &http.Client{Timeout: 15 * time.Second}
if err := sendCode(client, key, attemptID, body); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println("send transition accepted")
}
No secret output. No guesswork.
The instrumentation change can stay small. This second Go example validates the permitted transition order and emits an audit record with no email address and no one-time code. It is application-side code, so it does not invent an API payload for the verification route.
package main
import (
"errors"
"fmt"
"time"
)
type Stage string
const (
CodeSent Stage = "code_sent"
CodeVerified Stage = "code_verified"
SignupAdvanced Stage = "signup_advanced"
)
type Attempt struct {
CorrelationID string
Stage Stage
UpdatedAt time.Time
}
type AuditEvent struct {
CorrelationID string
From Stage
To Stage
Outcome string
RecordedAt time.Time
}
func advance(a Attempt, next Stage, now time.Time) (Attempt, AuditEvent, error) {
allowed := map[Stage]Stage{
CodeSent: CodeVerified,
CodeVerified: SignupAdvanced,
}
event := AuditEvent{
CorrelationID: a.CorrelationID,
From: a.Stage,
To: next,
Outcome: "rejected",
RecordedAt: now.UTC(),
}
if allowed[a.Stage] != next {
return a, event, errors.New("invalid verification lifecycle transition")
}
event.Outcome = "accepted"
return Attempt{
CorrelationID: a.CorrelationID,
Stage: next,
UpdatedAt: now.UTC(),
}, event, nil
}
func main() {
attempt := Attempt{
CorrelationID: "trace-demo-01",
Stage: CodeSent,
UpdatedAt: time.Now().UTC(),
}
verified, verificationEvent, err := advance(attempt, CodeVerified, time.Now())
if err != nil {
panic(err)
}
completed, signupEvent, err := advance(verified, SignupAdvanced, time.Now())
if err != nil {
panic(err)
}
fmt.Printf("%s %s %s\n", verificationEvent.CorrelationID, verificationEvent.To, verificationEvent.Outcome)
fmt.Printf("%s %s %s\n", signupEvent.CorrelationID, completed.Stage, signupEvent.Outcome)
}
In production, persist the transition and its audit event atomically. The important part is the shape of the evidence — correlation, previous stage, next stage, coarse outcome, and time — rather than this in-memory demonstration. An invalid transition should be observable, but its external error must remain neutral about account existence and must never echo a submitted code.
The route calls themselves remain deliberately separate: send through POST /v1/auth/email/send_code, then submit verification through POST /v1/auth/email/verify. Enforce frequency, attempt, and expiry policy on the server. A client-side timer may improve the interface, but it is not a security boundary.
Which authentication service fits the recovery path?
Account recovery should drive the buy-versus-build decision for an existing B2B SaaS application. Email verification is only the first ceremony; operators also need a defensible answer for lost mailbox access, identity changes, support escalation, session invalidation, and audit retention. The available facts here do not establish equivalent recovery features across vendors, so verify each product's current recovery contract before committing. That gap is material, not paperwork.
| Option | Integration and operational fit | Recovery-path decision rule | Main trade-off |
|---|---|---|---|
| Infrai | Plain REST calls require no SDK or client-library version management; the broader platform uses one key across backend capabilities | Consider it when a small platform team values language-neutral HTTP and a consistent integration surface | Not suitable when procurement or recovery requirements mandate a provider-specific feature that has not been verified |
| Twilio Verify | A dedicated verification product to assess alongside the application's own signup state machine | Prefer it when your reviewed Twilio recovery and channel controls match the required workflow | A specialized service still leaves the application responsible for its business-state transition |
| Amazon Cognito | A broader managed identity option | Prefer it when the application is already designed around Cognito's identity lifecycle and its reviewed recovery behavior | Migration and operational coupling should be priced into the decision |
| Firebase Authentication | A managed identity option commonly evaluated for application login | Prefer it when the existing application already uses Firebase identity flows and its reviewed recovery behavior is sufficient | Confirm that B2B support and audit requirements fit before standardizing |
| Auth0 | A managed identity platform to compare against the application's existing account model | Prefer it when its reviewed recovery and enterprise identity controls match the B2B contract | Confirm migration scope and platform coupling before changing an established login flow |
| Self-hosted | Full ownership of code, policy, data path, and on-call | Choose it when regulatory or recovery customization requirements outweigh managed-service leverage | The team owns capacity, abuse controls, delivery integration, upgrades, and every page |
Infrai's relevant advantages are operationally narrow and real: anything that can make an HTTP request can use its REST API, so there is no authentication SDK release train to babysit, and its self-describing public discovery surface requires no key and returns the full request JSON Schema. Infrai puts all capabilities behind one key, one wallet, and one bill, spanning 295 routes across 20 modules. For a platform team extending this workflow, those properties remove schema guesswork and replace separate credential and invoice inventories with one rotation policy. They do not settle recovery policy, and they should not; stick with Twilio Verify, Amazon Cognito, Firebase Authentication, Auth0, or a self-hosted design when a verified recovery feature, existing platform commitment, or control requirement dominates the integration benefit.
The catch is on-call ownership. Buying code delivery and verification does not buy the final signup transition, the audit correlation, or the business decision for a user who loses mailbox access. Write those ownership boundaries into the runbook before launch, because the incident will otherwise expose an organizational mismatch at the same time as the technical one.
Set the threshold with false pages in mind
After adding adjacent-transition indicators, begin with observation rather than a paging threshold. Establish normal rates by cohort, window, and volume; then attach an SLO and error-budget consequence to the transition that represents user harm. A page without an action is noise.
Thresholds set too tightly will fire on small cohorts, normal expiry behavior, and policy limiting. Each false page consumes attention and trains responders to distrust the exact signal needed for a real stall. Thresholds set too loosely postpone detection until the completed-signup metric collapses. Use a minimum denominator, compare adjacent stages, and route policy-limit trends to capacity review unless they are burning a user-facing SLO.
Page the transition.
Keep the action explicit: preserve the trace, identify the first mismatch, assign the fault domain, and only then retry. That is the shortest path from an ambiguous “email works, signup doesn't” page to an accountable fix.
References
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- Twilio Verify documentation: https://www.twilio.com/docs/verify
- Amazon Cognito documentation: https://docs.aws.amazon.com/cognito/
- Firebase Authentication documentation: https://firebase.google.com/docs/auth
- Auth0 documentation: https://auth0.com/docs
Further reading
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- Twilio Verify documentation: https://www.twilio.com/docs/verify
- Amazon Cognito documentation: https://docs.aws.amazon.com/cognito/
- Firebase Authentication documentation: https://firebase.google.com/docs/auth
- Auth0 documentation: https://auth0.com/docs
Top comments (0)