Spring Boot
Validate documents with Constaia from Spring Boot 3 and Java 21 using RestClient multipart, records, error handling and HMAC-verified webhooks.
This guide integrates Constaia into a Spring Boot 3 application (Java 21): a RestClient client that sends the
file as multipart, a controller that receives a MultipartFile, records for the response and a webhook endpoint
with a verified signature.
Official Java SDK: coming soon
There is no official Java SDK yet. Here you call the REST API directly. If you prefer a generated client, the
OpenAPI spec is at https://api.constaia.com/openapi.json (for example, with openapi-generator).
Requirements
- Java 21 and Spring Boot 3.2 or later (
RestClientexists since Spring Framework 6.1). - A test key
ck_test_…from the dashboard (see Authentication). - The
whsec_…secret of a webhook endpoint (see Webhooks).
Installation
You only need the web starter:
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
}With Maven, the equivalent dependency is org.springframework.boot:spring-boot-starter-web.
Configuration
constaia.api-key=${CONSTAIA_API_KEY}
constaia.webhook-secret=${CONSTAIA_WEBHOOK_SECRET}
spring.servlet.multipart.max-file-size=20MB
spring.servlet.multipart.max-request-size=21MBCONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...The key stays on the server; never send it to a frontend or a mobile app.
package com.example.constaia;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
@ConfigurationProperties(prefix = "constaia")
public record ConstaiaProperties(
String apiKey,
String webhookSecret,
@DefaultValue("https://api.constaia.com") String baseUrl) {
}package com.example.constaia;
import java.time.Duration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
@Configuration
@EnableConfigurationProperties(ConstaiaProperties.class)
public class ConstaiaConfig {
@Bean
RestClient constaiaRestClient(RestClient.Builder builder, ConstaiaProperties props) {
var requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(Duration.ofSeconds(10));
// A synchronous analysis can take up to 30 s before returning 202.
requestFactory.setReadTimeout(Duration.ofSeconds(60));
return builder
.baseUrl(props.baseUrl())
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + props.apiKey())
.requestFactory(requestFactory)
.build();
}
}Models
Records with the part of the analysis you use. Spring Boot configures Jackson to ignore unknown properties.
package com.example.constaia;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.List;
import java.util.Map;
public final class ConstaiaModels {
private ConstaiaModels() {
}
public record Analysis(
String id,
String status,
boolean livemode,
@JsonProperty("created_at") String createdAt,
DocumentInfo document,
Verdict verdict,
Map<String, Field> fields,
List<String> warnings,
AnalysisError error,
Map<String, String> metadata) {
}
public record DocumentInfo(String type, String label, double confidence) {
}
public record Verdict(List<String> expected, boolean match, String status, List<Reason> reasons) {
}
public record Reason(String code, String severity, String message) {
}
public record Field(Object value, Double confidence, Boolean validated) {
}
public record AnalysisError(String code, String message) {
}
public record ApiError(
String type,
String code,
String message,
String param,
@JsonProperty("request_id") String requestId) {
}
public record ErrorEnvelope(ApiError error) {
}
public record WebhookEvent(String type, @JsonProperty("created_at") String createdAt, JsonNode data) {
}
}Client
It sends multipart/form-data with the file part (with its filename) and the options part (JSON as text).
It uses one Idempotency-Key per operation and retries 429 responses honouring Retry-After with the same key, so
there is no double charge. Errors are read from the API's error envelope.
package com.example.constaia;
public class ConstaiaException extends RuntimeException {
private final int status;
private final String type;
private final String code;
private final String param;
private final String requestId;
private final long retryAfterSeconds;
public ConstaiaException(int status, String type, String code, String message,
String param, String requestId, long retryAfterSeconds) {
super(message);
this.status = status;
this.type = type;
this.code = code;
this.param = param;
this.requestId = requestId;
this.retryAfterSeconds = retryAfterSeconds;
}
public int status() { return status; }
public String type() { return type; }
public String code() { return code; }
public String param() { return param; }
public String requestId() { return requestId; }
public long retryAfterSeconds() { return retryAfterSeconds; }
}package com.example.constaia;
import com.example.constaia.ConstaiaModels.Analysis;
import com.example.constaia.ConstaiaModels.ApiError;
import com.example.constaia.ConstaiaModels.ErrorEnvelope;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.time.Duration;
import java.util.Map;
import java.util.UUID;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
@Service
public class ConstaiaClient {
private static final int MAX_RETRIES = 2;
private final RestClient restClient;
private final ObjectMapper objectMapper;
public ConstaiaClient(RestClient constaiaRestClient, ObjectMapper objectMapper) {
this.restClient = constaiaRestClient;
this.objectMapper = objectMapper;
}
/** 200 with status "completed" or 202 with status "queued"/"processing". */
public ResponseEntity<Analysis> analyze(byte[] file, String filename, Map<String, Object> options) {
String optionsJson;
try {
optionsJson = objectMapper.writeValueAsString(options);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException(e);
}
String idempotencyKey = UUID.randomUUID().toString();
for (int attempt = 0; ; attempt++) {
try {
return send(file, filename, optionsJson, idempotencyKey);
} catch (ConstaiaException e) {
if (e.status() != 429 || attempt >= MAX_RETRIES) {
throw e;
}
sleep(e.retryAfterSeconds());
}
}
}
public Analysis getAnalysis(String id) {
return restClient.get()
.uri("/v1/analyses/{id}", id)
.retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> {
throw toException(response);
})
.body(Analysis.class);
}
private ResponseEntity<Analysis> send(byte[] file, String filename, String optionsJson, String idempotencyKey) {
var parts = new MultipartBodyBuilder();
parts.part("file", new ByteArrayResource(file)).filename(filename);
parts.part("options", optionsJson);
return restClient.post()
.uri("/v1/analyze")
.header("Idempotency-Key", idempotencyKey)
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(parts.build())
.retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> {
throw toException(response);
})
.toEntity(Analysis.class);
}
private ConstaiaException toException(ClientHttpResponse response) throws IOException {
int status = response.getStatusCode().value();
ApiError error = null;
try {
error = objectMapper.readValue(response.getBody(), ErrorEnvelope.class).error();
} catch (IOException ignored) {
// Empty or non-JSON body (for example, an intermediate proxy).
}
String requestId = error != null && error.requestId() != null
? error.requestId()
: response.getHeaders().getFirst("X-Request-Id");
long retryAfter = 1;
String retryHeader = response.getHeaders().getFirst(HttpHeaders.RETRY_AFTER);
if (retryHeader != null) {
try {
retryAfter = Long.parseLong(retryHeader.trim());
} catch (NumberFormatException ignored) {
}
}
return new ConstaiaException(
status,
error != null ? error.type() : null,
error != null ? error.code() : null,
error != null && error.message() != null ? error.message() : "HTTP " + status,
error != null ? error.param() : null,
requestId,
retryAfter);
}
private static void sleep(long seconds) {
try {
Thread.sleep(Duration.ofSeconds(seconds));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
}
}
}The file part may not carry an exact Content-Type; that's fine, because the API detects the real type (JPEG,
PNG, WEBP, HEIC or PDF) from the content.
Controller that receives the document
Keep getOriginalFilename(): in test mode the result depends on the name. Your backend decides expect and
checks; don't take them as-is from the client.
package com.example.constaia;
import com.example.constaia.ConstaiaModels.Analysis;
import com.example.constaia.ConstaiaModels.Reason;
import java.io.IOException;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController
public class DocumentVerificationController {
private static final Logger log = LoggerFactory.getLogger(DocumentVerificationController.class);
private final ConstaiaClient constaia;
public DocumentVerificationController(ConstaiaClient constaia) {
this.constaia = constaia;
}
// Add your authentication here: every analysis spends credits.
@PostMapping(path = "/api/verify", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<Map<String, Object>> verify(
@RequestParam("file") MultipartFile file,
@RequestParam(name = "full_name", required = false) String fullName) throws IOException {
if (file.isEmpty()) {
return error(400, "The document is missing.");
}
Map<String, Object> checks = new HashMap<>();
checks.put("not_expired", true);
if (fullName != null && !fullName.isBlank()) {
checks.put("holder", Map.of("full_name", fullName));
}
Map<String, Object> options = Map.of(
"expect", "es_dni",
"checks", checks,
"storage", "none",
"language", "en");
Analysis analysis = constaia.analyze(
file.getBytes(),
Objects.requireNonNullElse(file.getOriginalFilename(), "document"),
options).getBody();
if (!"completed".equals(analysis.status())) {
// 202: the result will arrive by webhook (or call getAnalysis).
return ResponseEntity.accepted().body(Map.of("id", analysis.id(), "status", analysis.status()));
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("id", analysis.id());
body.put("status", analysis.verdict() != null ? analysis.verdict().status() : null);
body.put("reasons", analysis.verdict() != null
? analysis.verdict().reasons().stream().map(Reason::message).toList()
: java.util.List.of());
body.put("warnings", analysis.warnings());
return ResponseEntity.ok(body);
}
@ExceptionHandler(ConstaiaException.class)
public ResponseEntity<Map<String, Object>> onConstaiaError(ConstaiaException e) {
log.warn("Constaia {} {} request_id={}", e.status(), e.code(), e.requestId());
if ("invalid_request".equals(e.type())) {
return error(422, e.getMessage());
}
return error(502, "The document could not be verified. Please try again.");
}
private static ResponseEntity<Map<String, Object>> error(int status, String message) {
return ResponseEntity.status(status).body(Map.of("error", Map.of("message", message)));
}
}verdict.status is:
- Válido the type matches and there are no warnings: accept.
- No válido some reason with severity
error(wrong type, expired, holder mismatch…): reject. - Revisar some reason with severity
warning: human review.
code values are stable; message values are localised according to language. For a field:
analysis.fields().get("document_number").value(). More in Verdicts.
200 vs 202
A synchronous analysis waits up to 30 s. If it finishes, you get 200 with status: "completed"; if not, 202
with status: "queued" or "processing" and the result arrives by webhook. With "async", true in the options the
API answers 202 right away. ResponseEntity#getStatusCode() also tells you which one you got.
Webhook with verified signature
Receive the body as a String (@RequestBody String raw) to verify the signature over the exact content before
deserialising it.
package com.example.constaia;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Base64;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.stereotype.Component;
@Component
public class ConstaiaWebhookVerifier {
private static final long TOLERANCE_SECONDS = 300;
private final byte[] key;
public ConstaiaWebhookVerifier(ConstaiaProperties props) {
String secret = props.webhookSecret();
this.key = Base64.getDecoder().decode(secret.startsWith("whsec_") ? secret.substring(6) : secret);
}
public boolean isValid(String rawBody, String id, String timestamp, String signatures) {
if (id == null || timestamp == null || signatures == null) {
return false;
}
long ts;
try {
ts = Long.parseLong(timestamp);
} catch (NumberFormatException e) {
return false;
}
if (Math.abs(Instant.now().getEpochSecond() - ts) > TOLERANCE_SECONDS) {
return false;
}
byte[] expected;
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
expected = mac.doFinal((id + "." + timestamp + "." + rawBody).getBytes(StandardCharsets.UTF_8));
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
for (String part : signatures.split(" ")) {
int comma = part.indexOf(',');
if (comma < 0 || !part.substring(0, comma).equals("v1")) {
continue;
}
try {
if (MessageDigest.isEqual(Base64.getDecoder().decode(part.substring(comma + 1)), expected)) {
return true;
}
} catch (IllegalArgumentException ignored) {
}
}
return false;
}
}package com.example.constaia;
import com.example.constaia.ConstaiaModels.Analysis;
import com.example.constaia.ConstaiaModels.WebhookEvent;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ConstaiaWebhookController {
private static final Logger log = LoggerFactory.getLogger(ConstaiaWebhookController.class);
private final ConstaiaWebhookVerifier verifier;
private final ObjectMapper objectMapper;
public ConstaiaWebhookController(ConstaiaWebhookVerifier verifier, ObjectMapper objectMapper) {
this.verifier = verifier;
this.objectMapper = objectMapper;
}
@PostMapping("/webhooks/constaia")
public ResponseEntity<Void> receive(
@RequestBody String raw,
@RequestHeader(name = "webhook-id", required = false) String id,
@RequestHeader(name = "webhook-timestamp", required = false) String timestamp,
@RequestHeader(name = "webhook-signature", required = false) String signature) throws IOException {
if (!verifier.isValid(raw, id, timestamp, signature)) {
return ResponseEntity.badRequest().build();
}
WebhookEvent event = objectMapper.readValue(raw, WebhookEvent.class);
// webhook-id is stable across retries: dedupe on it (for example, a unique index in your database).
switch (event.type()) {
case "analysis.completed", "analysis.review_required", "analysis.failed" -> {
Analysis analysis = objectMapper.treeToValue(event.data(), Analysis.class);
log.info("{} {} {}", id, event.type(), analysis.id());
// Queue heavy work (@Async, a queue…) and answer within 15 s.
}
default -> {
// batch.completed, credits.low, test…
}
}
return ResponseEntity.noContent().build();
}
}If you use Spring Security, exclude the webhook route from CSRF, for example with
http.csrf(csrf -> csrf.ignoringRequestMatchers("/webhooks/constaia")), and permit it without a session: the
signature is the authentication.
Test mode
With a ck_test_… key no credits are spent and the result depends on the filename (it must be a real image or PDF:
rename any photo).
curl -F "file=@dni_valid.jpg" -F "full_name=María García López" http://localhost:8080/api/verify
curl -F "file=@dni_expired.jpg" http://localhost:8080/api/verify
curl -F "file=@blurry.jpg" http://localhost:8080/api/verify
curl -F "file=@dni_valid.jpg" -F "full_name=Juan Pérez" http://localhost:8080/api/verify| File | Verdict | Reason |
|---|---|---|
dni_valid.jpg | valid | "Valid until 12/03/2031." |
dni_expired.jpg | invalid | not_expired with severity error |
blurry.jpg | review | low_quality with severity warning; warnings blurry and low_quality |
dni_valid.jpg with full_name=Juan Pérez | invalid | holder with severity error |
More files in Test mode.
Errors
Error responses have the shape { "error": { "type", "code", "message", "param", "request_id" } }, which the
client turns into ConstaiaException. Branch on type() and code(), never on the message, and log requestId().
Full table in Errors.
Production checklist
- A
ck_live_…key (requires a verified email) in your secret manager or an environment variable. - Authentication and a per-user limit on
/api/verify: every analysis spends credits. spring.servlet.multipart.max-file-size=20MBso you don't forward files the API will reject.- One
Idempotency-Keyper logical operation, reused on retries (Idempotency). - Limit concurrency towards Constaia (2 req/s per key on the free plan, 10 on paid) (Rate limits).
- A webhook registered for
202responses andasyncanalyses; dedupe onwebhook-idand answer fast. - Review
storageandkeep_resultsin Storage and privacy.