Guides /

Go net/http

GO standard library

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_key

Set 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 /signup as 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.

Other guides

Get API key