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 statuses: valid, invalid, catch_all and unknown. Role and disposable arrive in the flags array beside the status, so test them separately. Unknown is the branch most code forgets, and its credit is refunded.

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"`
	Status     string   `json:"status"` // valid | invalid | catch_all | unknown
	ReasonCode string   `json:"reasonCode"`
	Flags      []string `json:"flags"` // role, disposable, free_provider
	SMTPCode   *int     `json:"smtpCode"`
	CheckedAt  string   `json:"checkedAt"`
}

func (c *Client) Verify(ctx context.Context, email string) (*Result, error) {
	body, _ := json.Marshal(map[string][]string{"emails": {email}})
	req, err := http.NewRequestWithContext(ctx, http.MethodPost,
		"https://app.zapbounce.com/api/v1/verify", bytes.NewReader(body))
	if err != nil {
		return nil, fmt.Errorf("build request: %w", err)
	}
	req.Header.Set("X-API-Key", 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 struct {
		Results []Result `json:"results"`
	}
	if err := json.NewDecoder(res.Body).Decode(&out); err != nil || len(out.Results) == 0 {
		return nil, fmt.Errorf("decode: %w", err)
	}
	return &out.Results[0], nil
}

Verify a list

Submit a job 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 job instead and let the server pace the probes.
// Up to 50,000 addresses per job. There is no webhook: poll until it finishes.
func (c *Client) SubmitJob(ctx context.Context, emails []string) (string, error) {
	var resp struct {
		ID string `json:"id"`
	}
	err := c.post(ctx, "/jobs", map[string]any{"emails": emails, "source": "api"}, &resp)
	return resp.ID, err
}

func (c *Client) WaitForJob(ctx context.Context, id string) error {
	for {
		var job struct {
			Status string `json:"status"`
		}
		if err := c.get(ctx, "/jobs/"+id, &job); err != nil {
			return err
		}
		switch job.Status {
		case "completed", "partial", "failed", "cancelled":
			return nil
		}
		select {
		case <-time.After(5 * time.Second):
		case <-ctx.Done():
			return ctx.Err()
		}
	}
}

// Rows come from GET /jobs/{id}/emails?page=N&pageSize=100 (meta.totalPages tells you when to stop),
// or stream GET /jobs/{id}/export for the whole job as CSV.

Errors and retries

A 429 and a 5xx are worth retrying. A 422 CREDITS_INSUFFICIENT 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:"requestId"`
	WindowSeconds int    // from X-RateLimit-Window-Seconds, set by parseAPIError
}

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

// Retryable reports whether another attempt could succeed. A 422 CREDITS_INSUFFICIENT 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 // CREDITS_INSUFFICIENT, VERIFY_EMPTY: stop here
		}
		if errors.Is(err, context.DeadlineExceeded) {
			// A timeout is not a bad address. Fail open.
			return &Result{Email: email, Status: "unknown", ReasonCode: "TIMEOUT"}, nil
		}
		// No Retry-After header exists: a 429 backs off from the window length.
		base := 500 * time.Millisecond
		if apiErr != nil && apiErr.Status == http.StatusTooManyRequests {
			base = time.Duration(max(apiErr.WindowSeconds, 1)) * time.Second
		}
		backoff := time.Duration(1<<attempt) * base
		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 job 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 other than unknown it spends a credit.

Run this Go code today

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