---
title: "How to Validate an Email Address in Go"
description: "Call /v1/check from Go with net/http, a context deadline, typed verdict structs, and fail-open signup handling for typo suggestions."
slug: "validate-email-in-go"
date: 2026-09-21
updated: 2026-09-21
last_tested: 2026-09-21
summary: "Use net/http and context.WithTimeout to validate signup emails in Go, map all four verdicts, and recover mistyped domains instead of rejecting them."
cluster: "Signup flows"
intent: how-to
sources:
  - title: "Go net/http package"
    url: "https://pkg.go.dev/net/http"
  - title: "Go context package"
    url: "https://pkg.go.dev/context"
  - title: "RFC 9457 — Problem Details for HTTP APIs"
    url: "https://www.rfc-editor.org/rfc/rfc9457.html"
  - title: "nobounce.dev OpenAPI specification"
    url: "https://nobounce.dev/openapi.json"
  - title: "nobounce.dev verdict taxonomy"
    url: "https://nobounce.dev/v1/config"
---

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.

```bash
curl -X POST https://nobounce.dev/demo/check \
  -H 'Content-Type: application/json' \
  -d '{"email":"user@gmai.com"}'
```

```json
{
  "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.

```bash
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:

```go
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

```go
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](https://nobounce.dev/blog/fail-open-email-validation-signup/).

## Map the verdict to a signup action

```go
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](https://nobounce.dev/blog/suggest-the-correction-not-rejection/).

## 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](https://nobounce.dev/blog/batch-validate-email-list/) 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](https://nobounce.dev/integrate.md).
