Go Email Validation Tutorial With a Gin Signup Handler
A Gin signup handler needs a narrower contract than “validate this email.” It should accept one JSON field, make one authenticated verification request, and return a decision that the rest of the application can act on.
input error 400 invalid_request
confirmed block 422 block
uncertain result 202 review
accepted result 200 allow
dependency error 503 retry
The implementation below puts the network call behind a Go interface. Production uses an http.Client with a four-second timeout; tests use a small fake and spend no credits.
Create the Gin module
mkdir gin-email-signup
cd gin-email-signup
go mod init example.com/gin-email-signup
go get github.com/gin-gonic/gin
Gin’s current binding documentation supports JSON tags and validator tags on request structs. This example uses ShouldBindJSON so the handler retains control of the response body.
Add the complete handler
Create main.go:
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"os"
"time"
"github.com/gin-gonic/gin"
)
const verifyURL = "https://emailverifier.dev/api/v1/verify"
type SignupRequest struct {
Email string `json:"email" binding:"required,email,max=254"`
}
type VerificationResult struct {
Email string `json:"email"`
Status string `json:"status"`
Action string `json:"action"`
Flagged bool `json:"flagged"`
Signals []string `json:"signals"`
Suggestion *string `json:"suggestion"`
}
type SignupResponse struct {
Decision string `json:"decision"`
Message string `json:"message"`
Signals []string `json:"signals,omitempty"`
Suggestion *string `json:"suggestion,omitempty"`
}
type Verifier interface {
Verify(context.Context, string) (VerificationResult, error)
}
type HTTPVerifier struct {
APIKey string
Client *http.Client
}
func (v HTTPVerifier) Verify(
ctx context.Context,
email string,
) (VerificationResult, error) {
body, err := json.Marshal(map[string]string{"email": email})
if err != nil {
return VerificationResult{}, err
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
verifyURL,
bytes.NewReader(body),
)
if err != nil {
return VerificationResult{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", v.APIKey)
response, err := v.Client.Do(req)
if err != nil {
return VerificationResult{}, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return VerificationResult{}, errors.New("verification API rejected request")
}
var result VerificationResult
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
return VerificationResult{}, err
}
return result, nil
}
func decision(result VerificationResult) (int, SignupResponse) {
switch result.Action {
case "block":
message := "Use a different email address."
if result.Suggestion != nil {
message = "Check the spelling of your email address."
}
return http.StatusUnprocessableEntity, SignupResponse{
Decision: result.Action,
Message: message,
Signals: result.Signals,
Suggestion: result.Suggestion,
}
case "review":
return http.StatusAccepted, SignupResponse{
Decision: result.Action,
Message: "Confirm this address before receiving full access.",
Signals: result.Signals,
}
case "allow":
return http.StatusOK, SignupResponse{
Decision: result.Action,
Message: "Continue to account creation.",
Signals: result.Signals,
}
default:
return http.StatusServiceUnavailable, SignupResponse{
Decision: "retry",
Message: "Email verification returned an unexpected response.",
}
}
}
func buildRouter(verifier Verifier) *gin.Engine {
router := gin.New()
router.Use(gin.Logger(), gin.Recovery())
router.POST("/signup", func(c *gin.Context) {
var input SignupRequest
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, SignupResponse{
Decision: "invalid_request",
Message: "Send a valid email address.",
})
return
}
result, err := verifier.Verify(c.Request.Context(), input.Email)
if err != nil {
c.JSON(http.StatusServiceUnavailable, SignupResponse{
Decision: "retry",
Message: "Email verification is temporarily unavailable.",
})
return
}
status, response := decision(result)
if response.Decision == "allow" {
// Create the account here, after the verification decision.
}
c.JSON(status, response)
})
return router
}
func main() {
apiKey := os.Getenv("EMAILVERIFIER_API_KEY")
if apiKey == "" {
panic("EMAILVERIFIER_API_KEY is not set")
}
verifier := HTTPVerifier{
APIKey: apiKey,
Client: &http.Client{Timeout: 4 * time.Second},
}
if err := buildRouter(verifier).Run(":8080"); err != nil {
panic(err)
}
}
HTTPVerifier.Verify contains the only outbound request. The handler does not retry automatically, so one submitted signup cannot quietly consume several verification credits.
Test the policy and call count
Create main_test.go:
package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
type fakeVerifier struct {
result VerificationResult
err error
calls int
}
func (f *fakeVerifier) Verify(
_ context.Context,
_ string,
) (VerificationResult, error) {
f.calls++
return f.result, f.err
}
func TestDisposableSignupIsBlockedAfterOneCheck(t *testing.T) {
gin.SetMode(gin.TestMode)
fake := &fakeVerifier{result: VerificationResult{
Status: "risky",
Action: "block",
Signals: []string{"disposable_address"},
}}
request := httptest.NewRequest(
http.MethodPost,
"/signup",
strings.NewReader(`{"email":"person@example.com"}`),
)
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
buildRouter(fake).ServeHTTP(response, request)
if response.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d", response.Code)
}
if fake.calls != 1 {
t.Fatalf("expected one verification call, got %d", fake.calls)
}
}
func TestMalformedEmailUsesNoCheck(t *testing.T) {
gin.SetMode(gin.TestMode)
fake := &fakeVerifier{}
request := httptest.NewRequest(
http.MethodPost,
"/signup",
strings.NewReader(`{"email":"not-an-email"}`),
)
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
buildRouter(fake).ServeHTTP(response, request)
if response.Code != http.StatusBadRequest || fake.calls != 0 {
t.Fatalf("expected local 400 and zero calls")
}
}
Run the suite:
go test ./...
Start the server and submit a signup
Create a project and copy its API key, then run:
export EMAILVERIFIER_API_KEY="your-project-key"
go run .
curl http://localhost:8080/signup \
-H "Content-Type: application/json" \
-d '{"email":"person@example.com"}'
The verification API can report deliverable, risky, undeliverable, or unknown. Your handler acts on the separate allow, review, or block recommendation. This prevents a catch-all response or a blocked SMTP probe from being treated as a nonexistent mailbox.
Role addresses and catch-all domains may be legitimate even when they deserve a different onboarding path. The role-address guide and catch-all guide own those policy decisions.