Email verification in Java
Java 11 shipped java.net.http.HttpClient, and it removed the last reason to pull in a third-party HTTP library for four endpoints. It handles HTTP/2, connection pooling and async out of the box.
Build the client once and share it. It is immutable and thread-safe by design, and constructing one per request throws away the connection pool that makes a long run fast.
java.net.http.HttpClient built once as a static final, Jackson for binding, and sendAsync where the caller wants concurrency.
Install
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.0</version>
</dependency>Verify one address
You branch on four results: valid, invalid, catch_all and unknown. Role and disposable are boolean flags beside the result, so test them separately. Unknown is the branch most code forgets, and it is never billed.
public final class ZapBounce {
private static final HttpClient CLIENT = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.connectTimeout(Duration.ofSeconds(3))
.build();
private static final ObjectMapper MAPPER = new ObjectMapper()
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
private final String key;
public ZapBounce(String key) { this.key = key; }
public VerifyResult verify(String email) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.zapbounce.com/v1/verify"))
.header("Authorization", "Bearer " + key)
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(12)) // per request, not on the client
.POST(HttpRequest.BodyPublishers.ofString(
MAPPER.writeValueAsString(Map.of("email", email))))
.build();
HttpResponse<String> response =
CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw ZapBounceException.from(response);
}
return MAPPER.readValue(response.body(), VerifyResult.class);
}
}
// FAIL_ON_UNKNOWN_PROPERTIES off is deliberate: new response fields are additive
// and must not break a client that has not been redeployed.
public record VerifyResult(String email, String result, String reason,
String smtpCode, boolean billed) {}Verify a list
Submit a batch rather than looping the single endpoint. We pace probes per receiving mail host, which protects the sending reputation your results depend on.
/** Concurrent single verifications. On Java 21, a virtual-thread executor
* lets you raise the pool without paying for platform threads. */
public List<VerifyResult> verifyAll(List<String> emails) {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<CompletableFuture<VerifyResult>> futures = emails.stream()
.map(email -> CompletableFuture.supplyAsync(() -> {
try {
return verify(email);
} catch (IOException | InterruptedException e) {
// A failure here is not a verdict. Do not record it as invalid.
return VerifyResult.unresolved(email);
}
}, executor))
.toList();
return futures.stream().map(CompletableFuture::join).toList();
}
}
/** For a real list, submit a batch and let the server pace probes per mail host. */
public String submitBatch(String name, List<String> emails) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.zapbounce.com/v1/batches"))
.header("Authorization", "Bearer " + key)
.header("Content-Type", "application/json")
.header("Idempotency-Key", UUID.randomUUID().toString())
.POST(HttpRequest.BodyPublishers.ofString(
MAPPER.writeValueAsString(Map.of("name", name, "emails", emails))))
.build();
HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
return MAPPER.readTree(response.body()).get("batch_id").asText();
}Errors and retries
A 429 and a 5xx are worth retrying. A 402 for credits and a 400 for a malformed address will fail the same way on every attempt, so stop.
public class ZapBounceException extends RuntimeException {
private final String code;
private final String requestId;
private final int status;
public boolean retryable() { return status == 429 || status >= 500; }
public static ZapBounceException from(HttpResponse<String> response) {
try {
JsonNode error = MAPPER.readTree(response.body()).path("error");
return new ZapBounceException(
error.path("code").asText("unknown"),
error.path("message").asText(response.body()),
error.path("request_id").asText(""),
response.statusCode());
} catch (JsonProcessingException e) {
return new ZapBounceException("unparseable", response.body(), "", response.statusCode());
}
}
}
public VerifyResult verifyWithRetry(String email) throws InterruptedException {
ZapBounceException last = null;
for (int attempt = 0; attempt < 5; attempt++) {
try {
return verify(email);
} catch (ZapBounceException e) {
if (!e.retryable()) throw e; // insufficient_credits will not improve
last = e;
} catch (HttpTimeoutException | IOException e) {
last = new ZapBounceException("io", e.getMessage(), "", 0);
}
Thread.sleep((long) (Math.pow(2, attempt) * 500 * (0.5 + Math.random())));
}
throw last;
}Java: common questions
Do I need OkHttp?
Not for this. The built-in client covers HTTP/2, pooling and async, and it is one fewer dependency in a service that handles other people's addresses.
Do virtual threads help?
For fan-out of single verifications, yes: the calls are I/O bound and the pool costs almost nothing on Java 21. For a list, the batch endpoint is still the right answer.
Why disable FAIL_ON_UNKNOWN_PROPERTIES?
Because we add response fields without a version bump. A strict binder turns an additive change into a production exception on a service nobody touched.
Run this Java code today
100 free checks a month, no card, credits that never expire, and unknown results that cost nothing.