A complete signup server using only the standard library. The handler asks MailVet, then either answers 422 or creates the user.
The code
1 Keep the key on the server
MAILVET_KEY=mv_live_your_keySet it wherever your server reads its environment. Never put it in client code.
2 The whole server
main.go
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"
)
type check struct {
Verdict string `json:"verdict"`
Disposable bool `json:"disposable"`
DidYouMean string `json:"did_you_mean"`
}
// MailVet gives DNS up to 4 seconds, so 5 covers a slow domain.
var client = &http.Client{Timeout: 5 * time.Second}
// lookup returns nil when MailVet gives no answer.
func lookup(ctx context.Context, email string) *check {
q := url.Values{"email": {email}}
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://mailvet.dev/v1/check?"+q.Encode(), nil)
if err != nil {
log.Printf("mailvet: %v", err)
return nil
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("MAILVET_KEY"))
res, err := client.Do(req)
if err != nil {
log.Printf("mailvet: no answer: %v", err)
return nil
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
log.Printf("mailvet: HTTP %d", res.StatusCode)
return nil
}
var c check
if err := json.NewDecoder(res.Body).Decode(&c); err != nil {
log.Printf("mailvet: bad answer: %v", err)
return nil
}
return &c
}
// problem says what to tell the user, or "" if the address can pass.
func problem(email string, c *check) string {
switch {
case c == nil:
return "" // a signup shouldn't die because of us
case c.DidYouMean != "":
local := email[:strings.LastIndex(email, "@")]
return fmt.Sprintf("Did you mean %s@%s?", local, c.DidYouMean)
case c.Verdict == "invalid":
return "That address can't receive mail."
case c.Verdict == "risky" && c.Disposable:
return "Please use a permanent address, not a throwaway one."
}
return ""
}
func signup(w http.ResponseWriter, r *http.Request) {
email := strings.TrimSpace(r.PostFormValue("email"))
if email == "" {
http.Error(w, "Enter your email.", http.StatusBadRequest)
return
}
if msg := problem(email, lookup(r.Context(), email)); msg != "" {
http.Error(w, msg, http.StatusUnprocessableEntity)
return
}
// Create the user here.
w.WriteHeader(http.StatusCreated)
}
func main() {
http.HandleFunc("POST /signup", signup)
log.Fatal(http.ListenAndServe(":8080", nil))
}What to do with the answer
problem turns did_you_mean, invalid and disposable into a message, and the handler answers 422 with it. Role addresses and a risky from a DNS failure return an empty string and go on to create the user.
- valid Let it through.
- risky Read
disposable. If it’s true, reject. If false, it’s a role address or DNS couldn’t be reached, so let it through. - invalid Bad format, or the domain has no mail server. Reject.
did_you_mean is empty or holds a domain, such as gmail.com for gamil.com. It doesn’t change the verdict. A known typo can come back valid because squatters give those domains a mail server, so check it first. See the docs for every field.
If MailVet is down, the signup goes through
lookup returns nil on a timeout, network error, non-200 answer or bad JSON, and logs why. problem treats nil as fine, so the signup carries on.
A 401 or a 429 takes the same path, so a bad key or a used-up quota means addresses pass unchecked. Watch your logs for it.
Watch out
POST /signupas a mux pattern needs Go 1.22 or newer.- The context comes from the request, so a browser that gives up also cancels the lookup. MailVet doesn't charge for a check whose client disconnected before the answer.
Sources
Compared with these on November 5, 2026. If they’ve changed since, trust them over this page.