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.
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 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.
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.
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:
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:
ObjectMapper mapper = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
Write one check with a hard deadline
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:
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.
Map the verdict to a signup action
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.
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 first.
For the complete key-acquisition, verdict-mapping, and verification procedure, give your coding agent /integrate.md.