How to Verify Email Addresses in Go with CheckMail
Updated · April 2026This guide walks through verifying email addresses in Go with the CheckMail API. You'll see single and batch verification, typed response structs, error handling, retries with exponential backoff, and a complete reusable client. Zero dependencies - everything runs on net/http and encoding/json from the standard library.
Go's net/mail package parses addresses but doesn't verify them. The net package can query MX records, but building a real verifier from scratch means wrangling SMTP handshakes, port 25 blocks on every major cloud provider, catch-all detection, and an ever-changing disposable-domain list. An API call is just faster.
§01Prerequisites
You need Go 1.21 or newer. Sign up for CheckMail and you'll get 100 free credits instantly. Keys look like cm_live_... for production and cm_test_... for sandboxing.
go version # 1.21+
mkdir email-verify && cd email-verify
go mod init example.com/email-verify
export CHECKMAIL_KEY=cm_live_xxxxxxxxxxxxxxxx
Read the key from os.Getenv. Never hardcode it into source. If the key leaks into git history, rotate it from the dashboard and the old one stops working.
§02Your first verification
A single verification is a GET to /v1/verify with the email as a query parameter and a bearer token in the Authorization header:
// main.go
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"time"
)
func main() {
key := os.Getenv("CHECKMAIL_KEY")
if key == "" {
fmt.Fprintln(os.Stderr, "set CHECKMAIL_KEY")
os.Exit(1)
}
u := "https://api.checkmail.dev/v1/verify?email=" + url.QueryEscape("john@example.com")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", u, nil)
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
panic(fmt.Sprintf("CheckMail returned %d", resp.StatusCode))
}
var data map[string]any
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
panic(err)
}
fmt.Printf("%+v\n", data)
}
Run go run main.go. You'll get back a map describing the address. One credit is deducted per definitive result; unknown verdicts are free. Always use context.WithTimeout - a stuck request with http.DefaultClient will otherwise hang your goroutine indefinitely.
§03Understanding the response
Here's the full JSON shape, plus a typed Go struct that decodes cleanly:
{
"email": "john@example.com",
"status": "valid",
"checks": {
"syntax": true,
"mx_found": true,
"smtp_valid": true,
"catch_all": false,
"disposable": false,
"role_based": false,
"free_provider": false
},
"suggestion": null,
"mx_host": "aspmx.l.google.com",
"cached": false,
"ms": 1243
}
type Checks struct {
Syntax bool `json:"syntax"`
MXFound bool `json:"mx_found"`
SMTPValid bool `json:"smtp_valid"`
CatchAll bool `json:"catch_all"`
Disposable bool `json:"disposable"`
RoleBased bool `json:"role_based"`
FreeProvider bool `json:"free_provider"`
}
type VerifyResult struct {
Email string `json:"email"`
Status string `json:"status"`
Checks Checks `json:"checks"`
Suggestion *string `json:"suggestion"`
MXHost string `json:"mx_host"`
Cached bool `json:"cached"`
MS int `json:"ms"`
}
The Status field is what you'll branch on most of the time:
valid- mailbox exists and will accept mail. Safe to send.invalid- bad syntax, no MX records, or the mail server rejected the address.catch_all- the domain accepts every address. Lower confidence.disposable- a throwaway service like Mailinator. Reject at signup.unknown- transient failure or greylisting. Retry later. Not charged.
The Suggestion field catches typos - enter john@gnail.con and you'll get "john@gmail.com" back. The full schema is in the docs.
§04Batch verification
For verifying lists - newsletter imports, CRM cleanup, periodic hygiene jobs - use the batch endpoint. Up to 500 addresses per request:
type BatchResponse struct {
Results []VerifyResult `json:"results"`
Charged int `json:"charged"`
}
func VerifyBatch(ctx context.Context, key string, emails []string) (*BatchResponse, error) {
body, _ := json.Marshal(map[string][]string{"emails": emails})
req, err := http.NewRequestWithContext(ctx, "POST",
"https://api.checkmail.dev/v1/verify/batch", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("batch failed: %d %s", resp.StatusCode, b)
}
var out BatchResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return &out, nil
}
Each result matches the single-verify schema. The Charged field reports the real deduction - unknown results are free, so a batch of 500 can legitimately bill at 487. Submitting more than 500 returns 400 batch_too_large, so chunk in the caller. Use a longer context timeout for batches since they take longer than single requests.
§05Error handling and retries
Client errors (4xx) are permanent - bad key, malformed request, quota exhausted - and retrying them won't help. Server errors (5xx) and rate limits (429) are transient. Retry with exponential backoff:
func VerifyWithRetry(ctx context.Context, client *http.Client, key, email string) (*VerifyResult, error) {
u := "https://api.checkmail.dev/v1/verify?email=" + url.QueryEscape(email)
for attempt := 1; attempt <= 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err == nil && resp.StatusCode == 200 {
defer resp.Body.Close()
var out VerifyResult
return &out, json.NewDecoder(resp.Body).Decode(&out)
}
if resp != nil {
// Permanent - don't retry
if resp.StatusCode >= 400 && resp.StatusCode < 500 && resp.StatusCode != 429 {
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("client error %d: %s", resp.StatusCode, b)
}
resp.Body.Close()
}
// Transient - back off
delay := time.Duration(1<<(attempt-1)) * time.Second
if delay > 10*time.Second {
delay = 10 * time.Second
}
select {
case <-time.After(delay):
case <-ctx.Done():
return nil, ctx.Err()
}
}
return nil, fmt.Errorf("gave up after 4 attempts")
}
The backoff is 1s, 2s, 4s, 8s - capped at 10. Always honor ctx.Done() inside the sleep so callers can cancel cleanly. At scale, add jitter (delay + time.Duration(rand.Intn(500))*time.Millisecond) so retries don't stampede when the API briefly hiccups.
§06Best practices
- Verify at signup. Call the API from your signup handler, server-side, before you persist the user. Bad data stopped at the door beats bad data cleaned up later.
- Don't block the HTTP request. A verification averages 1–2 seconds. For web handlers, either accept the signup optimistically and verify from a goroutine/worker, or set a tight context deadline and fail open on timeout.
- Reuse
http.Client. Don't create a new client per call - you lose connection pooling. Create one&http.Client{Timeout: 30*time.Second}at process startup and share it. - Cache results. Store the verdict on your user/contact record. We cache internally and mark
cached: true, but your own DB is faster and free. - Keep the key server-side. Never embed it in a mobile app, frontend JS, or CLI distributed to users.
- Handle
unknowngracefully. Not a failure - "ask again later." Schedule a retry in 10 minutes from a background worker. - Use
Suggestion. When non-nil, prompt the user: "Did you mean john@gmail.com?" Recovers a surprising number of typos.
§07Complete working example
A drop-in client with batching, retries, and a summary printer. Save as main.go:
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"
)
const (
apiBase = "https://api.checkmail.dev/v1"
batchSize = 500
)
type Client struct {
key string
http *http.Client
}
func NewClient(key string) *Client {
return &Client{
key: key,
http: &http.Client{Timeout: 60 * time.Second},
}
}
func (c *Client) VerifyMany(ctx context.Context, emails []string) (*BatchResponse, error) {
out := &BatchResponse{}
for i := 0; i < len(emails); i += batchSize {
end := i + batchSize
if end > len(emails) {
end = len(emails)
}
page, err := c.verifyChunk(ctx, emails[i:end])
if err != nil {
return nil, err
}
out.Results = append(out.Results, page.Results...)
out.Charged += page.Charged
fmt.Printf("Processed %d / %d\n", len(out.Results), len(emails))
}
return out, nil
}
func (c *Client) verifyChunk(ctx context.Context, emails []string) (*BatchResponse, error) {
body, _ := json.Marshal(map[string][]string{"emails": emails})
for attempt := 1; attempt <= 4; attempt++ {
req, _ := http.NewRequestWithContext(ctx, "POST",
apiBase+"/verify/batch", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+c.key)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err == nil && resp.StatusCode == 200 {
defer resp.Body.Close()
var out BatchResponse
return &out, json.NewDecoder(resp.Body).Decode(&out)
}
if resp != nil {
if resp.StatusCode >= 400 && resp.StatusCode < 500 && resp.StatusCode != 429 {
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("client error %d: %s", resp.StatusCode, b)
}
resp.Body.Close()
}
time.Sleep(time.Duration(1<<(attempt-1)) * time.Second)
}
return nil, fmt.Errorf("verify failed after retries")
}
func main() {
key := os.Getenv("CHECKMAIL_KEY")
if key == "" {
fmt.Fprintln(os.Stderr, "set CHECKMAIL_KEY")
os.Exit(1)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
client := NewClient(key)
payload, err := client.VerifyMany(ctx, []string{
"alice@acme.com",
"bob@example.org",
"typo@gnail.con",
"throwaway@mailinator.com",
})
if err != nil {
panic(err)
}
summary := map[string]int{}
for _, r := range payload.Results {
summary[r.Status]++
if r.Suggestion != nil {
fmt.Printf("%s → did you mean %s?\n", r.Email, *r.Suggestion)
}
}
fmt.Printf("%+v\n", summary)
fmt.Printf("Credits charged: %d\n", payload.Charged)
}
// Note: VerifyResult, Checks, BatchResponse structs from §03 / §04 should also be in this file.
// Left out here for brevity.
That's a production-ready verification pipeline. Wire it into your signup handler, a cron job, or a cobra CLI subcommand. The connection pooling on http.Client means thousands of verifications reuse the same TLS handshake.
§08Next steps
Grab an API key and start with 100 free credits. $2 per 1,000 after that. Credits never expire. For the full API reference - domain intelligence, account endpoints, auto-topup - see the docs.
Questions? mail@checkmail.dev