There is no Java SDK. For an API with one endpoint that matters, the wrapper is a RestClient bean and two records — well under a hundred lines including the failure handling. Writing it yourself means the timeout, the pool size and the fail-open policy are decisions you made rather than defaults you inherited, and in Spring the defaults are exactly where this goes wrong.
Three of those defaults will hurt you in production, and none of them has anything to do with fraud: RestClient ships with no read timeout, the stock HTTP client opens a fresh connection per call, and a @ControllerAdvice that has never seen a vendor outage will turn a detection blip into a 500 on your signup form.
Written for Spring Boot 3.2 or later on Java 21. Notes are included where WebClient or an older RestTemplate differ.
The browser half
The server call needs a token that only the browser can produce. One script tag in your layout, and a class on the form:
<!-- src/main/resources/templates/layout.html -->
<script async src="https://maskbreak.com/assets/sentinel.js">
</script>
<form method="post" action="/register" class="monocle-enriched">
<input type="email" name="email" required>
<input type="password" name="password" required>
<button type="submit">Create account</button>
</form>
The script injects a hidden monocle field into any form carrying that class, so the token arrives as an ordinary form parameter. For a JSON API, read it from the request body instead and post it under the same name. No API key goes anywhere near the browser — the collector needs none.
The client bean, with the timeouts spelled out
Spring's RestClient.builder() gives you no read timeout at all unless you set one. That means a vendor whose TCP connect succeeds but whose response never arrives will hold your request thread until something else gives up — and on a signup endpoint, "something else" is usually the user.
// src/main/java/com/example/fraud/FraudConfig.java
@Configuration
public class FraudConfig {
@Bean
RestClient maskbreakClient(
@Value("${maskbreak.key}") String key) {
var factory = new JdkClientHttpRequestFactory();
// Read timeout. Short on purpose: this sits in front of a
// user-facing action, so a slow answer is worth less than
// no answer at all.
factory.setReadTimeout(Duration.ofMillis(1500));
return RestClient.builder()
.baseUrl("https://maskbreak.com")
.defaultHeader("Authorization", "Bearer " + key)
.requestFactory(factory)
.build();
}
}
Two things about that factory choice. JdkClientHttpRequestFactory wraps the JDK's own HttpClient, which keeps connections alive between calls; the older SimpleClientHttpRequestFactory does not pool at all and will hand you a fresh TLS handshake on every signup, which is a self-inflicted hundred milliseconds. If Apache HttpClient 5 is already on your classpath, Spring picks HttpComponentsClientHttpRequestFactory automatically and you should size its pool explicitly — the default max per route is small enough to become your bottleneck before the vendor is.
The key comes from configuration, which means an environment variable or a secrets manager in production, never application.properties in the repository. It is a server-side credential.
Mapping the response
The response field is risk_score and your record component is riskScore. Jackson will not bridge that gap on its own, and the failure is silent: you get a valid object with a zero in it, every check passes, and nothing in your logs suggests why.
// src/main/java/com/example/fraud/Verdict.java
public record Verdict(
String decision, // allow | review | block
@JsonProperty("risk_score") int riskScore,
List<String> reasons,
Network network) {
public boolean blocked() { return "block".equals(decision); }
public boolean suspicious() { return !"allow".equals(decision); }
public record Network(boolean vpn, boolean proxy,
boolean datacenter, boolean tor) {}
}
Setting spring.jackson.property-naming-strategy=SNAKE_CASE globally would also work and would quietly rename every other DTO in your application, so annotate the one field instead. Add @JsonIgnoreProperties(ignoreUnknown = true) if you are not on the default configuration — the API adds fields additively, and a strict mapper turns a new field into an exception on a Tuesday.
The service, and the two catch blocks that matter
// src/main/java/com/example/fraud/FraudService.java
@Service
public class FraudService {
private static final Logger log =
LoggerFactory.getLogger(FraudService.class);
private final RestClient client;
FraudService(RestClient maskbreakClient) {
this.client = maskbreakClient;
}
/** Returns null when the verdict is unavailable. Null means
* "no opinion" — never "safe". */
public Verdict evaluate(String token, String accountId) {
if (token == null || token.isBlank()) return null;
try {
return client.post()
.uri("/v1/evaluate")
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("token", token,
"accountId", accountId == null ? "" : accountId))
.retrieve()
.body(Verdict.class);
} catch (HttpClientErrorException e) {
// 4xx is our bug: bad key, malformed body, expired token.
// Loud, because nobody finds this in a metric.
log.error("maskbreak rejected request: {}", e.getStatusCode());
return null;
} catch (ResourceAccessException | HttpServerErrorException e) {
// Timeout, DNS, connection reset, or their 5xx. Countable,
// not fatal.
log.warn("maskbreak unavailable: {}", e.getMessage());
return null;
}
}
}
Those two clauses are the whole design. A 4xx is a mistake you made and it will not fix itself, so it belongs at error where someone will see it. A timeout is weather. Returning null for both is deliberate: the calling code has to decide what an absent verdict means, and that decision differs between a newsletter form and a withdrawal.
Do not let this method throw. If a detection outage propagates out of here, your @ControllerAdvice turns it into a 500 and the vendor's bad afternoon becomes your bad afternoon.
Wire it once, not per controller
A HandlerInterceptor runs after Spring has worked out which handler serves the request, which means you can key the policy off an annotation instead of a URL list that drifts.
// Screen.java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Screen {
/** Refuse at this band or worse. */
String blockAt() default "block";
}
// FraudInterceptor.java
@Component
public class FraudInterceptor implements HandlerInterceptor {
private final FraudService fraud;
FraudInterceptor(FraudService fraud) { this.fraud = fraud; }
@Override
public boolean preHandle(HttpServletRequest req,
HttpServletResponse res,
Object handler) throws IOException {
if (!(handler instanceof HandlerMethod hm)) return true;
var screen = hm.getMethodAnnotation(Screen.class);
if (screen == null) return true;
var verdict = fraud.evaluate(req.getParameter("monocle"),
currentAccountId(req));
// Degraded, not hostile: ad blockers and CSP mistakes stop the
// collector for real users too.
if (verdict == null) return true;
req.setAttribute("verdict", verdict);
boolean refuse = "review".equals(screen.blockAt())
? verdict.suspicious()
: verdict.blocked();
if (refuse) {
res.setStatus(HttpStatus.FORBIDDEN.value());
res.setContentType("application/json");
res.getWriter().write("{\"error\":\"refused\"}");
return false;
}
return true;
}
}
Register it against the paths that need it, not against everything — screening a health check is a per-request cost with no upside:
@Configuration
class WebConfig implements WebMvcConfigurer {
private final FraudInterceptor interceptor;
WebConfig(FraudInterceptor i) { this.interceptor = i; }
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(interceptor)
.addPathPatterns("/register", "/login", "/checkout",
"/withdraw", "/api/**");
}
}
Then the policy lives on the handler, where the person changing it can see what it protects:
// Signup: refuse only a hard block. VPN users are customers.
@Screen
@PostMapping("/register")
public String register(@Valid SignupForm form) { ... }
// Money leaving: refuse anything that is not clean.
@Screen(blockAt = "review")
@PostMapping("/withdraw")
public String withdraw(@Valid PayoutForm form) { ... }
The review band is not a block
Most of the value here is in what you do with review, and the common mistake is treating it as a soft block. It is not a weaker refusal; it is the band where you do not have enough evidence to refuse and should buy some.
@PostMapping("/checkout")
public String checkout(@Valid Order order, HttpServletRequest req) {
var verdict = (Verdict) req.getAttribute("verdict");
if (verdict != null && "review".equals(verdict.decision())) {
return stepUp(order); // OTP, 3DS, manual queue
}
return charge(order);
}
A plain VPN lands in review on purpose. Blocking it refuses a large population of privacy-conscious paying customers to catch a small population of abusers, and the arithmetic almost never favours you. There is more on where to put each threshold in the risk score thresholds post.
Virtual threads, and why the pool still matters
On Java 21 with spring.threads.virtual.enabled=true, blocking on this call no longer pins a platform thread, and the instinct is to conclude that timeouts stopped mattering. They matter more. Virtual threads make it cheap to accumulate ten thousand requests all waiting on the same dead upstream, and the thing that runs out is not threads but connections and heap. The read timeout is what bounds that, and it is still not set by default.
If the upstream is properly down rather than slow, a circuit breaker saves you the queue entirely. With resilience4j-spring-boot3 on the classpath:
@CircuitBreaker(name = "maskbreak", fallbackMethod = "unavailable")
public Verdict evaluate(String token, String accountId) { ... }
private Verdict unavailable(String token, String accountId,
Throwable t) {
return null; // same contract: no opinion, not "safe"
}
The fallback returns exactly what the catch blocks return, which is the point. One meaning for "no verdict", decided in one place.
If you are on WebClient
The shape is the same and the timeout still needs saying out loud, because WebClient's default is also unbounded:
Mono<Verdict> verdict = webClient.post()
.uri("/v1/evaluate")
.bodyValue(Map.of("token", token))
.retrieve()
.bodyToMono(Verdict.class)
.timeout(Duration.ofMillis(1500))
.onErrorResume(e -> Mono.empty()); // no opinion
onErrorResume to an empty Mono is the reactive spelling of returning null, and timeout here is a wall clock on the whole exchange rather than on a single read, which is usually what you wanted anyway.
Make the fraud paths testable
A refusal path nobody exercises is a refusal path that breaks on the day it is needed. Deterministic test tokens let the block and review branches run in CI without a network call:
@Test
void blockedSignupIsRefused() throws Exception {
mockMvc.perform(post("/register")
.param("monocle", "test_token_block")
.param("email", "[email protected]"))
.andExpect(status().isForbidden());
}
Write the outage test too. Point the base URL at a socket that accepts and never answers, and assert that /register still returns 200. That single test is what stops a fail-open policy from quietly becoming a fail-closed one during a refactor.
The short version
- No SDK needed: a
RestClientbean, a record, an interceptor. - Set the read timeout. There is no default, and virtual threads make an unbounded wait worse rather than safer.
- Use a pooling request factory, and size the pool if Apache HttpClient is on the classpath.
- Annotate
risk_score. A silent zero is the worst failure mode in this integration. - 4xx is your bug and belongs at error; a timeout is weather and belongs at warn. Both mean "no opinion", never "safe".
- Key the policy off an annotation on the handler, so it is visible next to what it protects.
- Treat
reviewas a step-up, not a soft block. A plain VPN is a customer. - Test the block path and the outage path. Especially the outage path.
Frequently Asked Questions
Is there an official Java or Spring Boot SDK?
No. There are official Node, Python and PHP SDKs; Java integrations use plain HTTP. For a single endpoint that is a RestClient bean and a record — under a hundred lines with the error handling — and it leaves the timeout, pool and fail-open policy as decisions you made rather than defaults you inherited.
Why does my Verdict record always show a risk score of zero?
The JSON field is risk_score and the record component is riskScore, so Jackson has nothing to bind and leaves the primitive at its default. Annotate the component with @JsonProperty("risk_score"). Do not fix it by switching the whole application to SNAKE_CASE, which silently renames every other DTO you own.
What timeout should a fraud check use in Spring Boot?
Around one to two seconds of read timeout for a user-facing action, and you must set it explicitly because RestClient and WebClient both default to unbounded. Enabling virtual threads does not remove the need: it makes it cheap to pile up thousands of requests against a dead upstream, so the timeout is what bounds the damage.
Should the signup fail if the fraud API is down?
For signup and login, no — fail open, count the failure, and alert on the rate. A detection outage that takes registration down with it costs more than the abuse it would have caught. For irreversible money movement the trade flips: queue the payout for manual review rather than approving it blind. Either way the absent verdict means "no opinion", never "safe", and the calling code has to be the one that decides.
One bean, one interceptor, done
Maskbreak returns a decision, a score and the reasons behind it in a single call. Free tier: 1,000 requests per hour, no credit card.
Try Maskbreak free →