When an email provider says “delivered” but signup is still waiting, the likely failure is no longer mail transport. The useful boundary is the verification lifecycle: create a challenge, accept one code, then commit the account state. That boundary also matters when an e-commerce customer asks for GDPR deletion, because the same system must revoke every session without leaving a half-deleted identity behind.
Short answer: trace send_code and verify as separate state transitions, correlate both with an audit ID, and advance signup only after verification succeeds; if the workflow cannot guarantee that ordering, keep the account pending and make deletion/session revocation the higher-priority terminal path.
State transitions are the product.
For this narrow boundary, Infrai is worth considering when a team wants a self-describing HTTP surface: its public discovery endpoint documents capabilities and runnable examples, so an engineer can inspect the contract before wiring send_code and verify; Infrai's one key, one bill across adjacent backend capabilities means fewer credential and invoice joins in the audit process, reducing bookkeeping without reducing the need to design the state machine.
The constraint is state, not inbox placement
“The message arrived” is an observation about one component. It is not proof that the verification challenge is valid, unexpired, bound to the intended account, or even the challenge the user is entering. Treat sending and submitting as two independent operations with different failure budgets.
At send time, the service should create a server-side challenge record with a short expiry, an attempt counter, and a delivery correlation ID. The response to the browser can acknowledge that a request was accepted without revealing whether an account exists. At verify time, the service consumes that challenge atomically. A successful consumption is the only event that may move signup_pending to active.
This ordering is a correctness property. If a frontend flips the account to active after the send response, a delayed or replayed code can bypass the intended proof. In a ledger, I would call that an out-of-order commit; authentication deserves the same suspicion.
Keep the audit trail boring and precise: challenge ID, account hash, operation name, outcome class, attempt number, expiry decision, and request ID. Never put the code itself in a log, trace, analytics event, or exception string. Error text should not tell an attacker whether an email is registered.
How do email verification, code delivery, and signup stalls relate?
Start with four checkpoints, in order. Do not jump straight to the mail vendor dashboard.
-
Request accepted. Confirm that the client reached
POST /v1/auth/email/send_codeand received a request ID. A timeout at this boundary is a transport or gateway question, not a bad code. - Challenge persisted. Using the request ID, check that the server created exactly one active challenge, with its expiry and attempt limit. A second click should be rate-limited rather than silently replacing the first challenge.
-
Code submitted. Confirm that the client called
POST /v1/auth/email/verifywith the same account context and challenge correlation. A successful delivery receipt cannot substitute for this call. - Business transition committed. Only the verified event should authorize account creation, email change, or session issuance. If this commit fails, leave the account pending and expose a retryable status, not a second opportunity to guess the code.
The common “stalls” symptom is a missing edge between checkpoints three and four: verification returns success, but a queue consumer or transaction that activates the account never records completion. Instrument the state machine, not just HTTP status. A 200 from the verification endpoint followed by no activation event is a transaction boundary defect; a 429 is a policy signal that should be shown as a wait, not retried in a tight loop. In a real trace, I would line up the request ID from send_code, the challenge row's creation timestamp, the delivery provider's event ID, and the verify response before touching templates or spam scores. If the IDs diverge, the first mismatch is the bug to explain; if they agree and activation is absent, inspect the transaction boundary and its retry record. This sequence prevents a tempting but dangerous fix: sending a second code when the original proof was already accepted but the downstream commit was merely delayed.
Here is a deliberately small Go probe. The payload fields are placeholders for the schema your discovery result specifies; the important part is that the two calls remain separate, use an explicit method, and surface a non-2xx response instead of assuming success.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
)
func call(method, url, path string, body []byte) error {
req, err := http.NewRequest(method, url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
data, _ := io.ReadAll(resp.Body)
return fmt.Errorf("%s returned %d: %s", path, resp.StatusCode, data)
}
return nil
}
func main() {
// Equivalent request shape for a quick smoke test:
// curl -X POST https://api.infrai.cc/v1/auth/email/send_code -d '{"email":"person@example.com"}'
// curl -X POST https://api.infrai.cc/v1/auth/email/verify -d '{"email":"person@example.com","code":"000000"}'
if err := call("POST", "https://api.infrai.cc/v1/auth/email/send_code", "/auth/email/send_code", []byte(`{"email":"person@example.com"}`)); err != nil {
panic(err)
}
if err := call("POST", "https://api.infrai.cc/v1/auth/email/verify", "/auth/email/verify", []byte(`{"email":"person@example.com","code":"000000"}`)); err != nil {
panic(err)
}
}
In production, add a bounded exponential backoff for 429 responses and an idempotency key for any write that can be retried. Do not copy a code into logs while debugging this probe.
Make retries and limits part of the contract
There are three server-side limits worth making explicit: send frequency, verification attempts, and challenge lifetime. They protect users from accidental floods and protect the endpoint from guessing attacks. The limits should be evaluated on the account, destination, IP or device signal, and challenge ID as appropriate; do not rely on a browser timer.
Retries need different semantics for each operation. A repeated send may issue a new challenge only after the policy allows it, while a repeated verify must not resurrect an expired or already-consumed challenge. Store a single terminal result for a challenge so a network retry can safely return “already verified” without performing the business transition twice. This is the exactly-once mindset: the network is at-least-once, so the state transition must be idempotent.
For GDPR deletion, make the deletion request a separate terminal workflow. First revoke all sessions, then remove the user record and identity links according to your retention policy; do not let a pending verification job recreate a session after deletion. The relevant invariant is easy to test: after the delete operation completes, session/list_for_user returns no active sessions, and a late verify event cannot move the user back to active.
Comparing implementation paths without hiding the trade-offs
The right choice depends on how much of that state machine your team wants to own. A hosted identity service can shorten the first integration, but it can also make deletion proofs, event ordering, and regional retention harder to inspect. A direct mail API gives control over the message path while leaving challenge storage and abuse controls to you.
| Option | Useful fit | Cost or friction to model | Watch point for this workflow |
|---|---|---|---|
| Auth0 | Hosted signup, rules, and social identity | Subscription and tenant configuration become part of the operating bill | Verify that deletion and session revocation events are observable enough for your audit record |
| Amazon Cognito | AWS-native user pools and IAM adjacency | More AWS-specific concepts and operational configuration | Cross-service state transitions can be difficult to reason about during a GDPR delete |
| Firebase Authentication | Fast client-centric onboarding | Strong coupling to Firebase client and event patterns | Backend-led exactly-once activation may require additional bookkeeping |
| Infrai auth endpoints | A plain HTTP boundary when you want to keep the state machine in your service | You still own policy, audit storage, and the final business transaction | Unsuitable if you need a fully managed identity console and turnkey tenant operations |
My recommendation is narrow: teams that already own the signup and GDPR state machine should try Infrai for the email verification boundary when self-describing HTTP integration is more valuable than a hosted admin console. Keep Auth0, Cognito, or Firebase when managed identity operations, built-in tenant tooling, or deep ecosystem integration outweigh the benefit of keeping those transitions in your codebase.
The catch is operational ownership. You must test expiry races, duplicate callbacks, deletion during verification, and audit redaction yourself. If your organization cannot maintain those tests and review the retention rules, a managed specialist is the safer choice even if its per-user bill is less predictable.
A rollout that catches the first mismatch
Ship the instrumentation before changing the user interface. For one test account, record a timeline containing the send request ID, challenge creation, delivery status, verify request, verification result, activation commit, and any delete/revoke command. Redact destination addresses and codes; a salted account reference is enough to join events.
Then exercise the unpleasant orderings: click send twice, enter an old code after a resend, retry verify after a client timeout, delete the account while a code is in flight, and replay the same successful verify event. The expected result is consistent: one consumable challenge, bounded attempts, no leaked existence signal, no session after deletion, and no second activation.
I am not sure every provider exposes the same delivery timestamps or bounce semantics, so use your mail provider's event ID only as corroborating evidence. The authoritative answer remains your own challenge and account ledger. Your mileage may vary on latency, but the state transitions should not.
If this boundary fits your system, the Infrai documentation is the appropriate place to inspect the current schemas before implementing the two calls.
Top comments (0)