Go already ships the HTTP client and JSON decoder you need. net/http, context, and encoding/json are enough to call an email validation API without pulling in a third-party HTTP stack. The interesting work is the same as in any other language: keep the key server-side, bound the call with a deadline, and map four verdicts so a validator outage cannot become a signup outage.
This example is a small server-side helper for a registration handler. It returns an action the rest of your application can branch on.
Inspect the response shape with no credentials
POST /demo/check needs no key, but it is fixture-only. It resolves the frozen corpus listed by GET https://nobounce.dev/v1/config and returns real shapes for every verdict and reason. It cannot validate an arbitrary live address.
curl -X POST https://nobounce.dev/demo/check \
-H 'Content-Type: application/json' \
-d '{"email":"user@gmai.com"}'
{
"verdict": "undeliverable",
"reason": "typosquat_mx",
"suggestion": "user@gmail.com",
"confidence": 0.94,
"checked": { "syntax": true, "mx": true, "typo": true, "disposable": true },
"cached": false
}
Use the demo to write your branches. Switch to /v1/check only when you need live DNS.
Put the key in the process environment
There is no free tier. Live checks require a paid key: hobby is $1/mo for 1,000 checks, pro is $19/mo for 100,000, and scale is $99/mo for 1,500,000. Keys come from redeeming an operator-minted access code, or from paying self-serve on the Stripe card rail or the usevig stablecoin rail.
export NOBOUNCE_KEY="your-key-from-the-one-time-response"
Keep the key out of repositories, browser bundles, and mobile apps. Your signup handler calls nobounce; the browser calls your handler. The key belongs in an Authorization header, never in a URL or query parameter.
Model the frozen contract as Go types
The verdict object is frozen. Encoding it as structs keeps decoding honest and makes the fail-open signal (unknown with checked.mx == false) impossible to miss:
type Checked struct {
Syntax bool `json:"syntax"`
MX bool `json:"mx"`
Typo bool `json:"typo"`
Disposable bool `json:"disposable"`
}
type Verdict struct {
Verdict string `json:"verdict"`
Reason string `json:"reason"`
Suggestion *string `json:"suggestion"`
Confidence float64 `json:"confidence"`
Checked Checked `json:"checked"`
Cached bool `json:"cached"`
}
type Problem struct {
Error string `json:"error"`
Fix string `json:"fix"`
}
suggestion is nullable in the OpenAPI schema, so keep it a pointer. A missing suggestion and an empty string are different facts when you decide whether to prompt the user.
Write one check with a hard deadline
const checkURL = "https://nobounce.dev/v1/check"
func CheckEmail(ctx context.Context, client *http.Client, key, email string) (*Verdict, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
body, err := json.Marshal(map[string]string{"email": email})
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, checkURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
res, err := client.Do(req)
if err != nil {
return nil, err // timeout, DNS, transport — no opinion
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
var problem Problem
_ = json.NewDecoder(res.Body).Decode(&problem)
return nil, fmt.Errorf("nobounce %d: %s (%s)", res.StatusCode, problem.Error, problem.Fix)
}
var verdict Verdict
if err := json.NewDecoder(res.Body).Decode(&verdict); err != nil {
return nil, err
}
return &verdict, nil
}
Reuse one *http.Client across requests. Set a transport-level timeout if you want a second backstop, but the request context is the deadline that actually cancels the in-flight call. Errors are RFC 9457 problem details with a stable error and a plain-language fix; log those fields, not the submitted address. nobounce never stores a plaintext email address — anything persisted is SHA-256, while domains remain in clear.
Returning an error here means “the validator has no opinion.” It must not mean “reject the user.” That is the same fail-open policy covered in Fail Open: Email Validation Must Never Block a Signup.
Map the verdict to a signup action
type Action struct {
Kind string // accept | reject | suggest
Verdict string
Reason string
Suggestion string
MXChecked bool
}
func DecideEmailAction(ctx context.Context, client *http.Client, key, email string) Action {
result, err := CheckEmail(ctx, client, key, email)
if err != nil {
return Action{Kind: "accept", Verdict: "unchecked", MXChecked: false}
}
if result.Verdict == "undeliverable" && result.Suggestion != nil && *result.Suggestion != "" {
return Action{
Kind: "suggest",
Reason: result.Reason,
Suggestion: *result.Suggestion,
MXChecked: result.Checked.MX,
}
}
if result.Verdict == "undeliverable" {
return Action{Kind: "reject", Reason: result.Reason, MXChecked: result.Checked.MX}
}
return Action{
Kind: "accept",
Verdict: result.Verdict,
Reason: result.Reason,
MXChecked: result.Checked.MX,
}
}
| API result | application action |
|---|---|
deliverable |
accept |
risky |
accept, then apply your own policy if needed |
unknown |
accept and record checked.mx |
undeliverable without a suggestion |
reject with a reason-specific message |
undeliverable with a suggestion |
ask the user to confirm the correction |
risky covers disposable domains and role accounts such as admin@. Those addresses can receive mail, so blocking them is a product rule, not a validity fact. unknown with checked.mx: false is the frozen fail-open signal: DNS did not complete, so the API refuses to invent a deliverable answer.
The suggestion branch is the product. Render “Did you mean user@gmail.com?” with one-click acceptance and a keep-what-I-typed option. Do not silently rewrite the submitted value. Confidence is not certainty; the correction mechanics are covered in Suggest the Correction Instead of Rejecting the Signup.
Call it once, on submit
Wire DecideEmailAction into the HTTP handler that creates the account, not into a per-keystroke goroutine. Live DNS on every incomplete string burns quota and races the UI. Persist verdict, reason, and checked.mx so you can tell a completed check from a degraded one. Do not log the raw address. If a later cleanup job needs to join results, the batch endpoint keys each row by email_sha256; How to Batch-Validate a List of Email Addresses shows the hash join and the 1,000-address chunking loop.
Finally, test the timeout path. Point the helper at an unreachable host, or shrink the context deadline until it fires, and confirm registration still succeeds with MXChecked: false. A happy-path test proves the API works. A deadline test proves your signup survives when it does not.
For the complete key-acquisition, verdict-mapping, and verification procedure, give your coding agent /integrate.md.