Email verification API for Go

Verify one address or a whole list from Go in a few lines. The API is plain HTTPS and JSON: no SDK needed, one bearer key, and the examples below are complete and runnable.

Updated 2026-10-18

TL;DR

  • The Mail Exchange API is plain HTTPS and JSON at https://mailexchange.com/api/v1. No SDK needed.
  • POST /verify checks one address and usually answers in a few seconds. POST /lists checks up to 100,000 addresses and you poll for the result.
  • Each result has a state: deliverable, risky, undeliverable or unknown. The decision function below turns that into send, separate, skip, remove or retry.
  • Handle three statuses and you are done: 202 (still checking), 402 (out of credits) and 429 (slow down).

Get an API key

Keys are issued on request. Ask for one and we will set you up with free test credits. Every request sends the key as Authorization: Bearer your_key. Keep it in an environment variable, as the examples do, and out of source control. Credit prices are on the pricing page and credits never expire.

What you need

Go 1.18 or newer and the standard library only. Create a folder, run go mod init mxdemo, and save each example as main.go in its own folder.

Both programs are complete main packages, so keep them in separate folders. The shared call function decodes straight into a struct, and returns an *APIError you can inspect with errors.As to branch on Status (402 versus 429 versus 401). One http.Client is reused for every request, which is what you want. Pass a context.Context through http.NewRequestWithContext if you need cancellation in a long-running service.

Verify one address in Go

Save this as main.go. It defines a small call helper that sends the key, retries on 429, and turns error responses into an exception or error value with the message from the API. Then verify_one posts the address and handles the one tricky case: a slow mail server.

package main

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

const base = "https://mailexchange.com/api/v1"

var client = &http.Client{Timeout: 45 * time.Second}

type APIError struct {
	Status  int
	Message string
}

func (e *APIError) Error() string { return fmt.Sprintf("HTTP %d: %s", e.Status, e.Message) }

// Result is one verification result. The fields match the API response.
type Result struct {
	Email      string  `json:"email"`
	State      string  `json:"state"`
	Reason     string  `json:"reason"`
	Score      float64 `json:"score"`
	AcceptAll  bool    `json:"accept_all"`
	Role       bool    `json:"role"`
	Free       bool    `json:"free"`
	Disposable bool    `json:"disposable"`
	MX         string  `json:"mx"`
	ListID     int     `json:"list_id"` // only on a 202 pending response
}

// call makes one API request. It retries 429 with exponential backoff and
// returns *APIError for other error statuses. out may be nil.
func call(method, path string, in, out any) (int, error) {
	var payload []byte
	if in != nil {
		var err error
		if payload, err = json.Marshal(in); err != nil {
			return 0, err
		}
	}
	for attempt := 0; ; attempt++ {
		req, err := http.NewRequest(method, base+path, bytes.NewReader(payload))
		if err != nil {
			return 0, err
		}
		req.Header.Set("Authorization", "Bearer "+os.Getenv("MAILEXCHANGE_API_KEY"))
		req.Header.Set("Content-Type", "application/json")
		resp, err := client.Do(req)
		if err != nil {
			return 0, err
		}
		raw, err := io.ReadAll(resp.Body)
		resp.Body.Close()
		if err != nil {
			return resp.StatusCode, err
		}
		if resp.StatusCode == http.StatusTooManyRequests && attempt < 4 {
			time.Sleep(time.Duration(1<<attempt) * time.Second)
			continue
		}
		if resp.StatusCode >= 400 {
			var e struct {
				Error string `json:"error"`
			}
			_ = json.Unmarshal(raw, &e)
			if e.Error == "" {
				e.Error = string(raw)
			}
			return resp.StatusCode, &APIError{resp.StatusCode, e.Error}
		}
		if out != nil {
			if err := json.Unmarshal(raw, out); err != nil {
				return resp.StatusCode, err
			}
		}
		return resp.StatusCode, nil
	}
}

// decide turns a result into an action: send, separate, skip, remove or retry.
func decide(r Result) string {
	switch r.State {
	case "deliverable":
		return "send"
	case "risky":
		if r.Reason == "low_deliverability" { // catch-all domain
			return "separate"
		}
		return "skip" // role address or disposable
	case "undeliverable":
		return "remove"
	}
	return "retry" // unknown, or still pending
}

func verifyOne(email string) (Result, error) {
	var r Result
	status, err := call("POST", "/verify", map[string]string{"email": email}, &r)
	if err != nil || status != http.StatusAccepted {
		return r, err
	}
	// 202: the mail server is slow. The check is running and already paid for.
	// Do not post the address again, poll the list instead.
	for i := 0; i < 36; i++ {
		time.Sleep(5 * time.Second)
		var page struct {
			Results []Result `json:"results"`
		}
		if _, err := call("GET", fmt.Sprintf("/lists/%d/results?limit=1", r.ListID), nil, &page); err != nil {
			return r, err
		}
		if len(page.Results) > 0 && page.Results[0].State != "pending" {
			return page.Results[0], nil
		}
	}
	return r, nil // still pending after 3 minutes: look again later with the same list_id
}

func main() {
	if len(os.Args) < 2 {
		fmt.Fprintln(os.Stderr, "usage: go run . [email protected]")
		os.Exit(2)
	}
	res, err := verifyOne(os.Args[1])
	if err != nil {
		fmt.Fprintln(os.Stderr, "Mail Exchange error:", err)
		os.Exit(1)
	}
	fmt.Println(res.Email, res.State, res.Reason, "->", decide(res))
}

Run it:

MAILEXCHANGE_API_KEY=mx_live_xxx go run . [email protected]

Typical output looks like [email protected] deliverable accepted_email -> send.

About the 202. Most checks finish within a few seconds. If a mail server is slow, /verify waits about 20 seconds and then returns 202 with {"state": "pending", "list_id": ...}. That check is already running and was already charged. Posting the same address again would start a second check and charge again, so read the result from /lists/{list_id}/results instead, as the code does. More in errors, credits and rate limits.

Read the result and decide what to do

A result is a flat JSON object: email, state, reason, score, and the flags accept_all, role, free, disposable, plus mx. The state is what you branch on. The reason tells you why, and matters for the risky group. The decide function in the code above maps it like this:

statereasondecide() returnsWhy
deliverableaccepted_emailsendThe mail server accepted the mailbox.
riskylow_deliverabilityseparateCatch-all domain. Cannot be confirmed either way. Send carefully or keep apart.
riskylow_quality, disposableskipRole address (info@, sales@) or a throwaway domain.
undeliverablerejected_email, invalid_domain, invalid_emailremoveWill bounce. Drop it.
unknownunavailable_smtp, no_connect, dns_errorretryThe server did not give a clear answer. The credit is refunded.

The function is deliberately small so you can change it. A cold email team might send separate addresses only from established inboxes, as described in catch-all emails and cold email. A signup form might accept risky addresses but block undeliverable ones. The full list of states is in email verification statuses, and how it works explains the six checks behind them.

Verify a list in Go

For more than a handful of addresses, submit one list instead of looping over /verify. You get a list ID straight away, poll GET /lists/{id} until its status is done (or failed), then page through /lists/{id}/results 1,000 rows at a time. Duplicates inside the list are removed and not charged. Save this as main.go with one address per line in emails.txt (this one takes the addresses as arguments instead of a file).

package main

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

const base = "https://mailexchange.com/api/v1"

var client = &http.Client{Timeout: 45 * time.Second}

type APIError struct {
	Status  int
	Message string
}

func (e *APIError) Error() string { return fmt.Sprintf("HTTP %d: %s", e.Status, e.Message) }

// Result is one verification result. The fields match the API response.
type Result struct {
	Email      string  `json:"email"`
	State      string  `json:"state"`
	Reason     string  `json:"reason"`
	Score      float64 `json:"score"`
	AcceptAll  bool    `json:"accept_all"`
	Role       bool    `json:"role"`
	Free       bool    `json:"free"`
	Disposable bool    `json:"disposable"`
	MX         string  `json:"mx"`
	ListID     int     `json:"list_id"` // only on a 202 pending response
}

// call makes one API request. It retries 429 with exponential backoff and
// returns *APIError for other error statuses. out may be nil.
func call(method, path string, in, out any) (int, error) {
	var payload []byte
	if in != nil {
		var err error
		if payload, err = json.Marshal(in); err != nil {
			return 0, err
		}
	}
	for attempt := 0; ; attempt++ {
		req, err := http.NewRequest(method, base+path, bytes.NewReader(payload))
		if err != nil {
			return 0, err
		}
		req.Header.Set("Authorization", "Bearer "+os.Getenv("MAILEXCHANGE_API_KEY"))
		req.Header.Set("Content-Type", "application/json")
		resp, err := client.Do(req)
		if err != nil {
			return 0, err
		}
		raw, err := io.ReadAll(resp.Body)
		resp.Body.Close()
		if err != nil {
			return resp.StatusCode, err
		}
		if resp.StatusCode == http.StatusTooManyRequests && attempt < 4 {
			time.Sleep(time.Duration(1<<attempt) * time.Second)
			continue
		}
		if resp.StatusCode >= 400 {
			var e struct {
				Error string `json:"error"`
			}
			_ = json.Unmarshal(raw, &e)
			if e.Error == "" {
				e.Error = string(raw)
			}
			return resp.StatusCode, &APIError{resp.StatusCode, e.Error}
		}
		if out != nil {
			if err := json.Unmarshal(raw, out); err != nil {
				return resp.StatusCode, err
			}
		}
		return resp.StatusCode, nil
	}
}

// decide turns a result into an action: send, separate, skip, remove or retry.
func decide(r Result) string {
	switch r.State {
	case "deliverable":
		return "send"
	case "risky":
		if r.Reason == "low_deliverability" { // catch-all domain
			return "separate"
		}
		return "skip" // role address or disposable
	case "undeliverable":
		return "remove"
	}
	return "retry" // unknown, or still pending
}

type Summary struct {
	ID                int            `json:"id"`
	Status            string         `json:"status"`
	Total             int            `json:"total"`
	Checked           int            `json:"checked"`
	DuplicatesRemoved int            `json:"duplicates_removed"`
	Counts            map[string]int `json:"counts"`
}

func verifyList(emails []string) (Summary, []Result, error) {
	var s Summary
	body := map[string]any{"emails": emails, "name": "go-example"}
	if _, err := call("POST", "/lists", body, &s); err != nil {
		return s, nil, err
	}
	id := s.ID
	for s.Status != "done" && s.Status != "failed" {
		time.Sleep(5 * time.Second)
		if _, err := call("GET", fmt.Sprintf("/lists/%d", id), nil, &s); err != nil {
			return s, nil, err
		}
		fmt.Printf("%d/%d checked (%s)\n", s.Checked, s.Total, s.Status)
	}
	if s.Status == "failed" {
		return s, nil, fmt.Errorf("list %d failed", id)
	}

	var all []Result
	for offset := 0; ; offset += 1000 {
		var page struct {
			Results []Result `json:"results"`
		}
		path := fmt.Sprintf("/lists/%d/results?offset=%d&limit=1000", id, offset)
		if _, err := call("GET", path, nil, &page); err != nil {
			return s, nil, err
		}
		all = append(all, page.Results...)
		if len(page.Results) < 1000 {
			break
		}
	}
	return s, all, nil
}

func main() {
	if len(os.Args) < 2 {
		fmt.Fprintln(os.Stderr, "usage: go run . [email protected] [email protected] ...")
		os.Exit(2)
	}
	s, results, err := verifyList(os.Args[1:])
	if err != nil {
		fmt.Fprintln(os.Stderr, "Mail Exchange error:", err)
		os.Exit(1)
	}
	fmt.Println("counts:", s.Counts, "duplicates removed:", s.DuplicatesRemoved)
	actions := map[string]int{}
	for _, r := range results {
		actions[decide(r)]++
	}
	fmt.Println(actions)

	// optional: remove the list and its results
	if _, err := call("DELETE", fmt.Sprintf("/lists/%d", s.ID), nil, nil); err != nil {
		fmt.Fprintln(os.Stderr, "delete failed:", err)
	}
}
MAILEXCHANGE_API_KEY=mx_live_xxx go run . [email protected] [email protected] [email protected]

Instead of polling you can pass a callback_url and let us call you when the list is done, see webhooks and callbacks. The whole flow, including CSV download and deleting a list, is in verify a list with the API.

Errors, 402 and 429

Every error response is JSON with an error field, which the helper surfaces in the exception message. The ones to plan for:

  • 429 means you hit the per-key rate limit. The helper waits 1, 2, 4 and 8 seconds and tries again, which is safe because a rate-limited request does no work and costs nothing.
  • 402 means you do not have enough credits for the request. Retrying will not help. Stop, alert someone, and top up. GET /account returns your balance if you want to check first.
  • 401 means the key is missing or wrong. 400 means the request was malformed, for example no email, or a list over 100,000 addresses.

Details and a status code table are in errors, credits and rate limits.

Questions

Is there an official Go SDK for the Mail Exchange API?

No, and none is needed. The API is plain HTTPS and JSON, so the examples on this page use only the standard library.

How do I get an API key?

Keys are issued on request through the contact page, and they come with free test credits. Send the key as a bearer token in the Authorization header.

What should I do when /verify returns 202?

Do not send the address again. The check is already running and paid for. Read the result from /lists/{list_id}/results using the list_id in the 202 response, until the state is no longer pending.

Related