Email verification in Go

Go's default http.Client has no timeout at all, and http.DefaultClient is shared across your whole process. Both facts bite the same way: a slow mail server holds a goroutine open until something else gives up.

The client below is constructed once with explicit timeouts and a tuned transport, then passed around. Every call takes a context.Context, so a canceled request actually cancels.

One *http.Client with an explicit Timeout and a transport tuned for connection reuse. Context on every call, and errors wrapped so errors.As works.

Verify one address

You branch on four results: valid, invalid, catch_all and unknown. Role and disposable are boolean flags beside the result, so test them separately. Unknown is the branch most code forgets, and it is never billed.

Go
package zapbounce

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"time"
)

type Client struct {
	key  string
	http *http.Client
}

func New(key string) *Client {
	return &Client{
		key: key,
		http: &http.Client{
			Timeout: 15 * time.Second, // the zero value is no timeout at all
			Transport: &http.Transport{
				MaxIdleConnsPerHost: 20,
				IdleConnTimeout:     90 * time.Second,
			},
		},
	}
}

type Result struct {
	Email    string `json:"email"`
	Verdict  string `json:"result"`
	Reason   string `json:"reason"`
	SMTPCode string `json:"smtp_code"`
	Billed   bool   `json:"billed"`
}

func (c *Client) Verify(ctx context.Context, email string) (*Result, error) {
	body, _ := json.Marshal(map[string]string{"email": email})

	req, err := http.NewRequestWithContext(ctx, http.MethodPost,
		"https://api.zapbounce.com/v1/verify", bytes.NewReader(body))
	if err != nil {
		return nil, fmt.Errorf("build request: %w", err)
	}
	req.Header.Set("Authorization", "Bearer "+c.key)
	req.Header.Set("Content-Type", "application/json")

	res, err := c.http.Do(req)
	if err != nil {
		return nil, fmt.Errorf("verify %s: %w", email, err)
	}
	defer res.Body.Close() // leak this and connections are never reused

	if res.StatusCode != http.StatusOK {
		return nil, parseAPIError(res)
	}

	var out Result
	if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
		return nil, fmt.Errorf("decode: %w", err)
	}
	return &out, nil
}

Verify a list

Submit a batch rather than looping the single endpoint. We pace probes per receiving mail host, which protects the sending reputation your results depend on.

Go
// Single verifications in parallel, bounded. Unbounded goroutines against one
// host is how you rate-limit yourself and end up with a page of 429s.
func (c *Client) VerifyMany(ctx context.Context, emails []string, workers int) []Outcome {
	jobs := make(chan string)
	out := make(chan Outcome, len(emails))

	var wg sync.WaitGroup
	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for email := range jobs {
				r, err := c.Verify(ctx, email)
				out <- Outcome{Email: email, Result: r, Err: err}
			}
		}()
	}

	go func() {
		defer close(jobs)
		for _, e := range emails {
			select {
			case jobs <- e:
			case <-ctx.Done():
				return
			}
		}
	}()

	wg.Wait()
	close(out)

	results := make([]Outcome, 0, len(emails))
	for o := range out {
		results = append(results, o)
	}
	return results
}

// For a real list, submit a batch instead and let the server pace the probes.
func (c *Client) SubmitBatch(ctx context.Context, name string, emails []string) (string, error) {
	var resp struct {
		BatchID string `json:"batch_id"`
	}
	err := c.post(ctx, "/v1/batches", map[string]any{"name": name, "emails": emails}, &resp)
	return resp.BatchID, err
}

Errors and retries

A 429 and a 5xx are worth retrying. A 402 for credits and a 400 for a malformed address will fail the same way on every attempt, so stop.

Go
type APIError struct {
	Status    int
	Code      string `json:"code"`
	Message   string `json:"message"`
	RequestID string `json:"request_id"`
}

func (e *APIError) Error() string {
	return fmt.Sprintf("zapbounce: %s (%s, request_id=%s)", e.Message, e.Code, e.RequestID)
}

// Retryable reports whether another attempt could succeed. A 402 never will.
func (e *APIError) Retryable() bool {
	return e.Status == http.StatusTooManyRequests || e.Status >= 500
}

func (c *Client) VerifyWithRetry(ctx context.Context, email string) (*Result, error) {
	var last error
	for attempt := 0; attempt < 5; attempt++ {
		r, err := c.Verify(ctx, email)
		if err == nil {
			return r, nil
		}
		last = err

		var apiErr *APIError
		if errors.As(err, &apiErr) && !apiErr.Retryable() {
			return nil, err // insufficient_credits, malformed_email: stop here
		}

		backoff := time.Duration(1<<attempt) * 500 * time.Millisecond
		select {
		case <-time.After(backoff + jitter()):
		case <-ctx.Done():
			return nil, ctx.Err()
		}
	}
	return nil, fmt.Errorf("gave up after 5 attempts: %w", last)
}

Go: common questions

Is there a Go module?

No. The client here is about a hundred lines and has no dependency beyond the standard library.

How many workers should I run?

For single verifications, ten to twenty. Past that you are competing with your own rate limit. For anything list-shaped, use a batch and let the server pace it.

Does context cancellation stop the verification?

It stops your request. A probe already in flight on our side finishes, and if it produced a verdict it is billed.

Run this Go code today

100 free checks a month, no card, credits that never expire, and unknown results that cost nothing.