Spring Boot Email Validation Tutorial: Block Disposable Signups
The safest place to reject a disposable address is inside the signup request, before the application writes a user or sends a confirmation message. In Spring Boot, that means Bean Validation handles malformed input, one RestClient call gathers address evidence, and a small policy maps the result to an HTTP response.
This example targets Spring Boot 4.1 and Java 21. It makes exactly one external verification request for a syntactically valid signup.
Trace the request before writing the code
POST /signup
-> @Valid rejects malformed JSON or email syntax
-> EmailVerifierClient sends POST /api/v1/verify once
-> action=block -> 422, no user write
-> action=review -> 202, confirmation or restricted access
-> action=allow -> 200, continue to account creation
-> API failure -> 503, do not label the email invalid
The separation matters. An HTTP timeout is a service failure. It is not evidence that the submitted mailbox is missing.
Create the Spring Boot project
Generate a Maven project at Spring Initializr with Java 21, Spring Web, Validation, and Spring Boot 4.1.0. The current Spring Boot documentation recommends RestClient for imperative applications and documents Bean Validation through the validation starter.
Your relevant pom.xml dependencies should be:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Create an emailverifier.dev account and project, then copy its API key. Set it in the server environment:
export EMAILVERIFIER_API_KEY="your-project-key"
On PowerShell, use $env:EMAILVERIFIER_API_KEY="your-project-key". Do not place the key in browser JavaScript or commit it to application.properties.
Add the complete signup application
Replace the generated application class with src/main/java/dev/emailverifier/signup/SignupApplication.java:
package dev.emailverifier.signup;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.List;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
@SpringBootApplication
public class SignupApplication {
public static void main(String[] args) {
SpringApplication.run(SignupApplication.class, args);
}
@Bean
RestClient emailVerifierClient(
RestClient.Builder builder,
@Value("${EMAILVERIFIER_API_KEY}") String apiKey) {
var httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(4))
.build();
var requestFactory = new JdkClientHttpRequestFactory(httpClient);
requestFactory.setReadTimeout(Duration.ofSeconds(4));
return builder
.baseUrl("https://emailverifier.dev")
.defaultHeader("X-API-Key", apiKey)
.requestFactory(requestFactory)
.build();
}
record SignupRequest(@NotBlank @Email String email) {}
record VerificationRequest(String email) {}
record VerificationResult(
String email,
String status,
String action,
boolean flagged,
List<String> signals,
String suggestion) {}
record SignupResponse(String decision, String message, List<String> signals) {}
static ResponseEntity<SignupResponse> decide(VerificationResult result) {
return switch (result.action()) {
case "block" -> ResponseEntity.unprocessableEntity().body(
new SignupResponse(
"block",
result.suggestion() == null
? "Use a different email address."
: "Check the spelling of your email address.",
result.signals()));
case "review" -> ResponseEntity.status(HttpStatus.ACCEPTED).body(
new SignupResponse(
"review",
"Confirm this address before receiving full access.",
result.signals()));
case "allow" -> ResponseEntity.ok(
new SignupResponse(
"allow",
"Continue to account creation.",
result.signals()));
default -> throw new IllegalStateException("Unexpected verification action");
};
}
@RestController
static class SignupController {
private final RestClient client;
SignupController(RestClient emailVerifierClient) {
this.client = emailVerifierClient;
}
@PostMapping("/signup")
ResponseEntity<SignupResponse> signup(
@Valid @RequestBody SignupRequest input) {
VerificationResult result = client.post()
.uri("/api/v1/verify")
.body(new VerificationRequest(input.email()))
.retrieve()
.body(VerificationResult.class);
if (result == null) {
throw new RestClientException("Empty verification response");
}
ResponseEntity<SignupResponse> decision = decide(result);
if (decision.getStatusCode().is2xxSuccessful()
&& "allow".equals(decision.getBody().decision())) {
// Create the user here. No user mutation occurs before this boundary.
}
return decision;
}
@ExceptionHandler(RestClientException.class)
ResponseEntity<SignupResponse> verificationUnavailable() {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(
new SignupResponse(
"retry",
"Email verification is temporarily unavailable.",
List.of()));
}
}
}
The outbound call is the single client.post() expression. The submitted email is the only request-body field sent to emailverifier.dev. Passwords and profile fields stay inside your application.
Test the policy without spending credits
Create src/test/java/dev/emailverifier/signup/SignupApplicationTests.java:
package dev.emailverifier.signup;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import org.junit.jupiter.api.Test;
class SignupApplicationTests {
@Test
void disposableAddressIsBlocked() {
var report = new SignupApplication.VerificationResult(
"person@temporary.example",
"risky",
"block",
true,
List.of("disposable_address"),
null);
var response = SignupApplication.decide(report);
assertThat(response.getStatusCode().value()).isEqualTo(422);
assertThat(response.getBody().decision()).isEqualTo("block");
}
@Test
void inconclusiveAddressIsReviewed() {
var report = new SignupApplication.VerificationResult(
"person@example.com",
"unknown",
"review",
false,
List.of("verification_inconclusive"),
null);
assertThat(SignupApplication.decide(report).getStatusCode().value())
.isEqualTo(202);
}
@Test
void deliverableAddressIsAllowed() {
var report = new SignupApplication.VerificationResult(
"person@example.com",
"deliverable",
"allow",
false,
List.of("mailbox_accepts_mail"),
null);
assertThat(SignupApplication.decide(report).getStatusCode().value())
.isEqualTo(200);
}
}
Run ./mvnw test. These tests exercise the application policy with fixed evidence and consume no verification credits.
Run one real signup request
./mvnw spring-boot:run
Submit a signup from another terminal:
curl http://localhost:8080/signup \
-H "Content-Type: application/json" \
-d '{"email":"person@example.com"}'
The route can return 200 allow, 202 review, or 422 block. Invalid local input returns 400. A verification transport or upstream failure returns 503 retry.
The API result itself uses deliverable, risky, undeliverable, or unknown for status, plus allow, review, or block for action. The production result-handling guide explains why those two fields should remain separate.
Keep the API key on the server, keep unknown results out of the hard-failure bucket, and place the database write after the allow decision.