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 a handful of 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

XML
<dependency>
  <groupId>com.fasterxml.jackson.core</groupId>
  <artifactId>jackson-databind</artifactId>
  <version>2.17.0</version>
</dependency>

Verify one address

You branch on four statuses: valid, invalid, catch_all and unknown. Role and disposable arrive in the flags array beside the status, so test them separately. Unknown is the branch most code forgets, and its credit is refunded.

Java
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()
            .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://app.zapbounce.com/api/v1/verify"))
                .header("X-API-Key", key)
                .header("Content-Type", "application/json")
                .timeout(Duration.ofSeconds(12))   // per request, not on the client
                .POST(HttpRequest.BodyPublishers.ofString(
                        MAPPER.writeValueAsString(Map.of("emails", List.of(email)))))
                .build();

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

        if (response.statusCode() != 200) {
            throw ZapBounceException.from(response);
        }
        // The answer is {"node": "...", "results": [ ... ]}. One email in, one result out.
        JsonNode first = MAPPER.readTree(response.body()).path("results").get(0);
        return MAPPER.treeToValue(first, 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 id, String email, String status, String reasonCode,
                           String provider, List<String> flags, Integer smtpCode,
                           String checkedAt) {}

Verify a list

Submit a job rather than looping the single endpoint. We pace probes per receiving mail host, which protects the sending reputation your results depend on.

Java
/** 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 job (up to 50,000 emails) and let the server pace probes per mail host. */
public String submitJob(List<String> emails) throws IOException, InterruptedException {
    HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://app.zapbounce.com/api/v1/jobs"))
            .header("X-API-Key", key)
            .header("Content-Type", "application/json")
            .header("Idempotency-Key", UUID.randomUUID().toString())
            .POST(HttpRequest.BodyPublishers.ofString(
                    MAPPER.writeValueAsString(Map.of("emails", emails, "source", "api"))))
            .build();

    HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
    return MAPPER.readTree(response.body()).get("id").asText();
}

/** No webhook: poll until the job is completed, partial, failed or cancelled. */
public JsonNode waitForJob(String id) throws IOException, InterruptedException {
    while (true) {
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create("https://app.zapbounce.com/api/v1/jobs/" + id))
                .header("X-API-Key", key)
                .timeout(Duration.ofSeconds(30))
                .build();
        JsonNode job = MAPPER.readTree(CLIENT.send(request, HttpResponse.BodyHandlers.ofString()).body());
        if (Set.of("completed", "partial", "failed", "cancelled").contains(job.path("status").asText())) {
            return job;
        }
        Thread.sleep(5_000);
    }
}
// Then page GET /v1/jobs/{id}/emails?page=N&pageSize=100 until meta.totalPages,
// or stream GET /v1/jobs/{id}/export as CSV.

Errors and retries

A 429 and a 5xx are worth retrying. A 422 CREDITS_INSUFFICIENT and a 400 for a malformed address will fail the same way on every attempt, so stop.

Java
public class ZapBounceException extends RuntimeException {
    private final String code;
    private final String requestId;
    private final int status;
    private final int windowSeconds;   // X-RateLimit-Window-Seconds, 0 when absent

    public boolean retryable() { return status == 429 || status >= 500; }

    public static ZapBounceException from(HttpResponse<String> response) {
        int window = response.headers().firstValue("X-RateLimit-Window-Seconds")
                .map(Integer::parseInt).orElse(0);
        try {
            JsonNode error = MAPPER.readTree(response.body());   // statusCode, code, message, requestId
            return new ZapBounceException(
                    error.path("code").asText("unknown"),
                    error.path("message").asText(response.body()),
                    error.path("requestId").asText(""),
                    response.statusCode(), window);
        } catch (JsonProcessingException e) {
            return new ZapBounceException("unparseable", response.body(), "", response.statusCode(), window);
        }
    }
}

public VerifyResult verifyWithRetry(String email) throws InterruptedException {
    ZapBounceException last = null;
    for (int attempt = 0; attempt < 5; attempt++) {
        long floorMs = 500;
        try {
            return verify(email);
        } catch (ZapBounceException e) {
            if (!e.retryable()) throw e;    // CREDITS_INSUFFICIENT (422) will not improve
            last = e;
            // There is no Retry-After header: a 429 backs off from the window length.
            if (e.status == 429) floorMs = Math.max(1, e.windowSeconds) * 1000L;
        } catch (HttpTimeoutException e) {
            // A timeout is not a bad address. Fail open.
            return VerifyResult.unresolved(email);   // status "unknown", reasonCode "TIMEOUT"
        } catch (IOException e) {
            last = new ZapBounceException("io", e.getMessage(), "", 0, 0);
        }
        Thread.sleep((long) (Math.pow(2, attempt) * floorMs * (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 jobs 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 refunded.