Spring Boot
Valida documentos con Constaia desde Spring Boot 3 y Java 21 usando RestClient multipart, records, manejo de errores y webhooks HMAC verificados.
Esta guía integra Constaia en una aplicación Spring Boot 3 (Java 21): un cliente con RestClient que envía el
fichero en multipart, un controlador que recibe un MultipartFile, records para la respuesta y un endpoint de
webhooks con la firma verificada.
SDK oficial de Java: próximamente
Todavía no hay SDK oficial para Java. Aquí llamas a la API REST directamente. Si prefieres un cliente generado, la
especificación OpenAPI está en https://api.constaia.com/openapi.json (por ejemplo, con openapi-generator).
Requisitos
- Java 21 y Spring Boot 3.2 o superior (
RestClientexiste desde Spring Framework 6.1). - Una clave de test
ck_test_…del panel (ver Autenticación). - El secreto
whsec_…de un endpoint de webhook (ver Webhooks).
Instalación
Solo necesitas el starter web:
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
}Con Maven, la dependencia equivalente es org.springframework.boot:spring-boot-starter-web.
Configuración
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_...La clave se queda en el servidor; nunca la envíes a un frontend ni a una app móvil.
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));
// El análisis síncrono puede tardar hasta 30 s antes de devolver 202.
requestFactory.setReadTimeout(Duration.ofSeconds(60));
return builder
.baseUrl(props.baseUrl())
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + props.apiKey())
.requestFactory(requestFactory)
.build();
}
}Modelos
Records con la parte del análisis que usas. Spring Boot configura Jackson para ignorar propiedades desconocidas.
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) {
}
}Cliente
Envía multipart/form-data con la parte file (con su nombre de fichero) y la parte options (JSON en texto).
Usa una Idempotency-Key por operación y reintenta los 429 respetando Retry-After con la misma clave, así que no
hay doble cobro. Los errores se leen del sobre error de la API.
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 con status "completed" o 202 con 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) {
// Cuerpo vacío o no JSON (por ejemplo, un proxy intermedio).
}
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);
}
}
}La parte file no lleva un Content-Type exacto; da igual, porque la API detecta el tipo real (JPEG, PNG, WEBP,
HEIC o PDF) por su contenido.
Controlador que recibe el documento
Conserva getOriginalFilename(): en modo test el resultado depende del nombre. Tu backend decide expect y
checks; no los aceptes tal cual del cliente.
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;
}
// Añade aquí tu autenticación: cada análisis consume créditos.
@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, "Falta el documento.");
}
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", "es");
Analysis analysis = constaia.analyze(
file.getBytes(),
Objects.requireNonNullElse(file.getOriginalFilename(), "document"),
options).getBody();
if (!"completed".equals(analysis.status())) {
// 202: el resultado llegará por webhook (o consulta 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, "No se pudo verificar el documento. Inténtalo de nuevo.");
}
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 vale:
- Válido el tipo coincide y no hay avisos: acepta.
- No válido alguna razón con severidad
error(tipo distinto, caducado, titular distinto…): rechaza. - Revisar alguna razón con severidad
warning: revisión humana.
Los code son estables; los message vienen traducidos según language. Para un campo:
analysis.fields().get("document_number").value(). Más en Veredictos.
200 frente a 202
El análisis síncrono espera hasta 30 s. Si termina, recibes 200 con status: "completed"; si no, 202 con
status: "queued" o "processing" y el resultado llega por webhook. Con "async", true en las opciones la API
responde 202 al momento. ResponseEntity#getStatusCode() también te dice cuál de los dos recibiste.
Webhook con firma verificada
Recibe el cuerpo como String (@RequestBody String raw) para verificar la firma sobre el contenido exacto antes
de deserializarlo.
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 es estable entre reintentos: deduplica con él (por ejemplo, índice único en tu base de datos).
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());
// Encola el trabajo pesado (@Async, cola…) y responde en menos de 15 s.
}
default -> {
// batch.completed, credits.low, test…
}
}
return ResponseEntity.noContent().build();
}
}Si usas Spring Security, excluye la ruta del webhook de CSRF, por ejemplo con
http.csrf(csrf -> csrf.ignoringRequestMatchers("/webhooks/constaia")), y permítela sin sesión: la autenticación
es la firma.
Probar en modo test
Con una clave ck_test_… no se gastan créditos y el resultado depende del nombre del fichero (tiene que ser una
imagen o un PDF real: renombra cualquier foto).
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| Fichero | Veredicto | Motivo |
|---|---|---|
dni_valid.jpg | valid | "Vigente hasta el 12/03/2031." |
dni_expired.jpg | invalid | not_expired con severidad error: "Caducado el 15/06/2020." |
blurry.jpg | review | low_quality con severidad warning; avisos blurry y low_quality |
dni_valid.jpg con full_name=Juan Pérez | invalid | holder con severidad error |
Más ficheros en Modo test.
Errores
Las respuestas de error tienen la forma { "error": { "type", "code", "message", "param", "request_id" } }, que el
cliente convierte en ConstaiaException. Decide por type() y code(), nunca por el mensaje, y registra
requestId(). Tabla completa en Errores.
Checklist de producción
- Clave
ck_live_…(requiere email verificado) en tu gestor de secretos o variable de entorno. - Autenticación y límite por usuario en
/api/verify: cada análisis consume créditos. spring.servlet.multipart.max-file-size=20MBpara no reenviar ficheros que la API rechazará.- Una
Idempotency-Keypor operación lógica, reutilizada en los reintentos (Idempotencia). - Limita la concurrencia hacia Constaia (2 req/s por clave en el plan gratuito, 10 en el de pago) (Límites).
- Webhook registrado para los
202y los análisisasync; deduplica porwebhook-idy responde rápido. - Revisa
storageykeep_resultsen Almacenamiento y privacidad.