---
title: "How to Validate an Email Address in Node.js"
description: "Use Node's built-in fetch, a bounded timeout, and explicit handling for all four email-validation verdicts, including typo suggestions."
slug: "validate-email-in-nodejs"
date: 2026-09-20
updated: 2026-09-20
last_tested: 2026-09-20
summary: "Call /v1/check from server-side Node.js, fail open when the request cannot finish, and turn a suggested correction into a recoverable signup instead of an error."
cluster: "Signup flows"
intent: how-to
sources:
  - title: "Node.js globals — fetch and AbortSignal.timeout"
    url: "https://nodejs.org/api/globals.html"
  - title: "MDN — Using the Fetch API"
    url: "https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch"
  - 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"
---

Node.js already has the HTTP client you need. On current Node releases, built-in `fetch` and `AbortSignal.timeout()` are enough to call an email validation API without adding Axios or another runtime dependency. The important work is not the request itself. It is keeping the API key on the server, bounding the call, and mapping four verdicts without turning a validator outage into a signup outage.

This example calls nobounce from a server-side registration handler and returns a small action object for the rest of your application.

## Inspect the contract before sending live addresses

`POST /demo/check` needs no credentials, but it is fixture-only. It accepts the frozen addresses listed by `GET https://nobounce.dev/v1/config` and returns real response shapes for every verdict and reason. It cannot validate an arbitrary address or replace the production endpoint.

You can exercise it directly from Node:

```js
const response = await fetch("https://nobounce.dev/demo/check", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ email: "user@gmai.com" }),
});

console.log(await response.json());
```

The typo fixture returns an `undeliverable` verdict with a `suggestion` such as `user@gmail.com`. Use the demo to test your branches, then use `/v1/check` for live DNS.

## Keep the key in server-side configuration

There is no free tier. Live checks require a 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. A key comes from redeeming an operator-minted access code or paying self-serve through the Stripe card rail or usevig stablecoin rail.

Load the resulting key from a server environment variable:

```bash
export NOBOUNCE_KEY="your-key-from-the-one-time-response"
```

Do not expose it through `NEXT_PUBLIC_*`, `VITE_*`, browser JavaScript, or a mobile bundle. The browser should call your server; your server should call nobounce. The key belongs in the `Authorization` header, never in a URL or query parameter.

## Write one bounded check function

```js
const CHECK_URL = "https://nobounce.dev/v1/check";

export async function checkEmail(email) {
  try {
    const response = await fetch(CHECK_URL, {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${process.env.NOBOUNCE_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ email }),
      signal: AbortSignal.timeout(2000),
    });

    if (!response.ok) {
      const problem = await response.json().catch(() => null);
      console.warn("email validation unavailable", {
        status: response.status,
        error: problem?.error,
        fix: problem?.fix,
      });
      return null;
    }

    return await response.json();
  } catch (error) {
    console.warn("email validation request failed", {
      name: error instanceof Error ? error.name : "UnknownError",
    });
    return null;
  }
}
```

Every API error uses RFC 9457 problem details and includes a stable `error` plus a plain-language `fix`. Log those fields, not the submitted address. No plaintext email address is persisted by nobounce; anything persisted is SHA-256, while domains remain in clear.

The function deliberately returns `null` for timeouts, network failures, malformed upstream responses, and non-2xx statuses. `null` means “the validator has no opinion.” It must not mean “reject the user.” This is the same fail-open policy explained in [Fail Open: Email Validation Must Never Block a Signup](https://nobounce.dev/blog/fail-open-email-validation-signup/).

## Map the result to a signup action

```js
export async function decideEmailAction(email) {
  const result = await checkEmail(email);

  if (result === null) {
    return { action: "accept", verdict: "unchecked", mxChecked: false };
  }

  if (result.verdict === "undeliverable" && result.suggestion) {
    return {
      action: "suggest",
      suggestion: result.suggestion,
      confidence: result.confidence,
      reason: result.reason,
    };
  }

  if (result.verdict === "undeliverable") {
    return { action: "reject", reason: result.reason };
  }

  return {
    action: "accept",
    verdict: result.verdict,
    reason: result.reason,
    mxChecked: result.checked.mx,
  };
}
```

That mapping produces four behaviours:

| 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 can receive mail, so blocking them is a product rule rather than a validity fact. `unknown` means the check did not complete. In particular, `unknown` with `checked.mx: false` is the frozen fail-open signal.

The suggestion branch is the valuable one. Render “Did you mean `user@gmail.com`?” with one-click acceptance and a “keep what I typed” option. Do not silently rewrite the field: 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 on submit, not on every keystroke

Validation belongs in your server-side form action, API route, or registration service. Call it once when the user submits, or optionally after field blur through your own server endpoint. Calling live DNS on every keystroke wastes quota on incomplete strings and introduces avoidable races in the UI.

Persist enough metadata to distinguish a completed check from a degraded one: `verdict`, `reason`, and `checked.mx`. Do not log the raw address. If a later cleanup job needs to correlate results, the batch endpoint returns `email_sha256`; [How to Batch-Validate a List of Email Addresses](https://nobounce.dev/blog/batch-validate-email-list/) shows the hash join and 1,000-address chunking loop.

Finally, test the failure path. Temporarily point `CHECK_URL` at an unreachable host or reduce the timeout until it fires, then confirm registration still succeeds with `mxChecked: false`. A happy-path test proves the API works. A timeout 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).
