How to Verify Email Addresses in Go with CheckMail

Updated · April 2026

This 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:

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

§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