The clean way to call an external API from Spring Boot is a dedicated service bean wrapping a RestClient (the synchronous client introduced in Spring Framework 6.1) or WebClient if you’re reactive. You configure the base URL, timeouts and the Authorization header once when you build the client, inject the service where you need it, and deserialize the response into a record DTO. The non-negotiable rule: keep the API key server-side — in application.yml backed by an environment variable or a secrets manager — never in an Android/desktop client or anything a user can download.
Why verify on the server
Verification needs your secret vae_... key, and it only stays secret on infrastructure you control: your Spring Boot service or a background worker. Never embed it in a mobile app, a single-page frontend, or a compiled artifact a user can decompile — a leaked key gets its credits drained quickly. Read it from configuration and inject it. The broader checklist is in how to protect your API keys.
The response DTO
Model only the fields you branch on. Two records mirror the { "ok": ..., "result": { ... } } shape; Jackson maps the camelCase JSON automatically.
// VerifyResponse.java
public record VerifyResponse(boolean ok, VerificationResult result) {
public record VerificationResult(
String status, // deliverable | undeliverable | risky | unknown
String subStatus,
int score,
String suggestion) {
}
}Configuration
Put the key and base URL in application.yml, sourcing the secret from an environment variable so it never lands in version control.
# application.yml
verifyanyemail:
base-url: https://api.verifyany.email
api-key: ${VAE_API_KEY} # resolved from the environment at startupThe RestClient service
Build the RestClient as a bean with explicit connect and read timeouts — a live SMTP probe can occasionally be slow, and an unbounded read timeout would tie up a request thread. Attach the Bearer key as a default header so the call sites stay clean.
// EmailVerificationService.java
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
@Service
public class EmailVerificationService {
private final RestClient client;
public EmailVerificationService(
@Value("${verifyanyemail.base-url}") String baseUrl,
@Value("${verifyanyemail.api-key}") String apiKey) {
var factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout((int) Duration.ofSeconds(5).toMillis());
factory.setReadTimeout((int) Duration.ofSeconds(10).toMillis());
this.client = RestClient.builder()
.baseUrl(baseUrl)
.requestFactory(factory)
.defaultHeader("Authorization", "Bearer " + apiKey)
.defaultHeader("X-VAE-Source", "spring-boot")
.build();
}
/** Returns empty when verification could not run, so callers can fail open. */
public Optional<VerifyResponse.VerificationResult> verify(String email) {
try {
VerifyResponse body = client.post()
.uri("/v1/verify")
.body(Map.of("email", email))
.retrieve()
.body(VerifyResponse.class);
return Optional.ofNullable(body).map(VerifyResponse::result);
} catch (RestClientException ex) {
// Timeout, network error, or non-2xx → empty → caller fails open.
return Optional.empty();
}
}
}Add a retry for transient failures
Only transient problems — timeouts, 5xx, 429 rate-limits — should be retried, and always with backoff. A clean undeliverable is a real answer, not a failure, so never retry a successful 200. The simplest robust option is Spring Retry: add spring-retry plus spring-boot-starter-aop, put @EnableRetry on a config class, and annotate the call.
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;
@Retryable(
retryFor = { HttpServerErrorException.class, ResourceAccessException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 500, multiplier = 2.0)) // 0.5s, 1s, 2s
public Optional<VerifyResponse.VerificationResult> verifyWithRetry(String email) {
return verify(email);
}Note that ResourceAccessException covers read timeouts and connection failures, and HttpServerErrorException covers 5xx. Leave 4xx (except 429) out of the retry set — a 400 won’t fix itself on a second attempt.
Branch on the status
The API returns one of four values in result.status(). Map each to a clear action.
@RestController
public class SignupController {
private final EmailVerificationService verifier;
public SignupController(EmailVerificationService verifier) {
this.verifier = verifier;
}
@PostMapping("/api/signup")
public ResponseEntity<?> signup(@RequestBody SignupRequest req) {
var maybe = verifier.verify(req.email());
// Fail open: if verification couldn't run, don't block a real user.
if (maybe.isEmpty()) {
return register(req, false);
}
String status = maybe.get().status();
return switch (status) {
// Mailbox doesn't exist → block; it would hard-bounce.
case "undeliverable" -> ResponseEntity.badRequest()
.body(Map.of("error", "undeliverable_email",
"suggestion", String.valueOf(maybe.get().suggestion())));
// Reachable but lower confidence → allow, flag for review.
case "risky" -> register(req, true);
// "deliverable" and "unknown" (inconclusive) → allow.
default -> register(req, false);
};
}
private ResponseEntity<?> register(SignupRequest req, boolean flagForReview) {
// ... your real signup logic ...
return ResponseEntity.ok(Map.of("flagged", flagForReview));
}
public record SignupRequest(String email) {}
}- undeliverable — mailbox or domain can’t receive mail. Block it; sending guarantees a hard bounce.
- deliverable — real, reachable mailbox. Allow it.
- risky — reachable but catch-all, role-based (
info@,support@) or disposable. Allow, optionally flag. - unknown — inconclusive (greylisting, timeout, temporary unreachability). Fail open and allow — blocking here punishes real users for a transient condition.
For the reasoning behind handling each status this way at a signup form, see verify emails at signup without hurting conversion.
Test it without spending credits
Any address at @test.verifyany.email returns a deterministic result and costs 0 credits — no real mailbox is probed. Use them in your integration tests to cover every branch:
deliverable@test.verifyany.email→deliverableundeliverable@test.verifyany.email→undeliverablerisky@test.verifyany.email→riskyunknown@test.verifyany.email→unknown
You can also check a single address in the browser with the free Email Verifier.
Errors, timeouts and retries recap
- Timeouts are explicit. Connect (5s) and read (10s) are set on the request factory, so a slow probe can never hang a thread indefinitely.
- Fail open on timeouts,
5xxand429— return the customer through rather than blocking on your side’s hiccup. - Retry only transient failures with exponential backoff; never retry a successful verdict.
- Handle
402loudly — out-of-credits should raise an alert, not silently fail every check.
To verify a whole list instead of one address at a time, submit the batch endpoint and consume its signed batch.completed webhook rather than looping — see how to process large verification batches.
Wrapping up
A Spring Boot service around RestClient gives you typed, testable email verification: configure base URL, timeouts and the Bearer key once, deserialize into a record, retry only transient failures, and branch on result.status(). Block only undeliverable, fail open on unknown and errors, keep the key in configuration, and test every path against @test.verifyany.email for free. The same server-side pattern appears in .NET & C# and Next.js; the full reference is in the API docs.