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:

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:

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

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.

Map the result to a signup action

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.

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 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.