---
title: "How to Validate an Email Address in Java"
description: "Call /v1/check from Java 17+ with java.net.http.HttpClient, request timeouts, Jackson records for the frozen verdict object, and fail-open signup handling."
slug: "validate-email-in-java"
date: 2026-10-08
updated: 2026-10-08
last_tested: 2026-10-08
summary: "Use the JDK's own HttpClient and a couple of records to validate signup emails in Java, map all four verdicts, and recover mistyped domains instead of rejecting them."
cluster: "Signup flows"
intent: how-to
sources:
  - title: "Java SE HttpClient API documentation"
    url: "https://docs.oracle.com/en/java/javase/21/docs/api/java.net.http/java/net/http/HttpClient.html"
  - title: "Java SE HttpRequest.Builder API documentation"
    url: "https://docs.oracle.com/en/java/javase/21/docs/api/java.net.http/java/net/http/HttpRequest.Builder.html"
  - title: "Jackson databind"
    url: "https://github.com/FasterXML/jackson-databind"
  - title: "RFC 9457 — Problem Details for HTTP APIs"
    url: "https://www.rfc-editor.org/rfc/rfc9457.html"
  - title: "nobounce.dev verdict taxonomy"
    url: "https://nobounce.dev/v1/config"
---

Java 11 shipped a real HTTP client in the JDK. `java.net.http.HttpClient` handles timeouts, headers, and JSON bodies natively, so calling an email validation API needs one dependency at most: a JSON deserializer. 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 can never become a signup outage.

This example is a server-side helper for a registration endpoint. It returns an action the rest of your application can branch on. It uses Java 17 records; on Java 11, write the same shapes as final classes.

## 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 response 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 endpoint 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 controller calls nobounce; the browser calls your controller. The key belongs in an `Authorization` header, never in a URL or query parameter.

```java
String key = System.getenv("NOBOUNCE_KEY");
```

## Model the frozen contract as records

The verdict object is frozen. Encoding it as records keeps decoding honest and makes the fail-open signal (`unknown` with `checked.mx == false`) impossible to miss:

```java
public record Checked(boolean syntax, boolean mx, boolean typo, boolean disposable) {}

public record Verdict(
        String verdict,
        String reason,
        String suggestion,   // nullable: absent and empty are different facts
        double confidence,
        Checked checked,
        boolean cached
) {}

public record Problem(String error, String fix) {}
```

Jackson deserializes records directly. Configure it to ignore unknown properties, because the taxonomy is frozen for rewording, not for extension — new fields may be added:

```java
ObjectMapper mapper = new ObjectMapper()
        .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
```

## Write one check with a hard deadline

```java
private static final URI CHECK_URI = URI.create("https://nobounce.dev/v1/check");

public record CheckResult(Verdict verdict, Problem problem, int status) {}

public static CheckResult checkEmail(HttpClient client, ObjectMapper mapper,
        String key, String email) throws IOException, InterruptedException {

    String body = mapper.writeValueAsString(Map.of("email", email));

    HttpRequest request = HttpRequest.newBuilder(CHECK_URI)
            .timeout(Duration.ofSeconds(2))
            .header("Authorization", "Bearer " + key)
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
            .build();

    HttpResponse<String> response =
            client.send(request, HttpResponse.BodyHandlers.ofString());

    if (response.statusCode() != 200) {
        Problem problem = mapper.readValue(response.body(), Problem.class);
        return new CheckResult(null, problem, response.statusCode());
    }
    return new CheckResult(mapper.readValue(response.body(), Verdict.class), null, 200);
}
```

Build the `HttpClient` once, not per request, with its own connection timeout:

```java
HttpClient client = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(2))
        .build();
```

`HttpClient.send` throws `IOException` on transport failures and `InterruptedException` on cancellation — both mean "the validator has no opinion", not "reject the user". A request-level timeout via `HttpRequest.Builder.timeout` bounds the whole call, which is the deadline that matters for signup latency. Errors come back as RFC 9457 problem details with a stable `error` and a plain-language `fix`; log those fields, not the submitted address. nobounce never persists a plaintext address — anything stored is SHA-256, while domains remain in clear.

That error channel 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

```java
public record Action(String kind, String verdict, String reason,
        String suggestion, boolean mxChecked) {}

public static Action decideAction(HttpClient client, ObjectMapper mapper,
        String key, String email) {
    CheckResult result;
    try {
        result = checkEmail(client, mapper, key, email);
    } catch (IOException | InterruptedException e) {
        return new Action("accept", "unknown", "unreachable", null, false);
    }

    if (result.verdict() == null) {
        return new Action("accept", "unknown",
                "http_" + result.status(), null, false);
    }

    Verdict v = result.verdict();
    boolean hasSuggestion = v.suggestion() != null && !v.suggestion().isBlank();

    return switch (v.verdict()) {
        case "undeliverable" -> hasSuggestion
                ? new Action("suggest", v.verdict(), v.reason(), v.suggestion(),
                        v.checked().mx())
                : new Action("reject", v.verdict(), v.reason(), null, v.checked().mx());
        default -> new Action("accept", v.verdict(), v.reason(), null, v.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 `suggest` 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. The `reason` field tells you *why* a correction was computed — `typosquat_mx` means the typed domain resolves to a known typo-domain mail host, `likely_typo` means the domain itself was repaired by edit distance. Those 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 `decideAction` into the controller that creates the account, not into a per-keystroke endpoint. 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, and never log the raw address. If a later cleanup job needs to join results back, the batch endpoint keys each row by `email_sha256`, which also keeps the storage layer free of address-shaped columns.

Finally, test the deadline path. Point the helper at an unreachable host — `URI.create("https://localhost:1/")` is enough — and assert the method returns the `accept`/`unknown` action within roughly your timeout budget. A happy-path test proves the API works. A deadline test proves your signup survives when it does not. If you retry instead of failing open, read [Retry an Email Validation Call the Right Way](https://nobounce.dev/blog/retry-email-validation-with-idempotency-key/) first.

For the complete key-acquisition, verdict-mapping, and verification procedure, give your coding agent [/integrate.md](https://nobounce.dev/integrate.md).
