Constaia
Integraciones

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 (RestClient existe 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:

build.gradle.kts
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
}

Con Maven, la dependencia equivalente es org.springframework.boot:spring-boot-starter-web.

Configuración

src/main/resources/application.properties
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=21MB
.env
CONSTAIA_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.

src/main/java/com/example/constaia/ConstaiaProperties.java
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) {
}
src/main/java/com/example/constaia/ConstaiaConfig.java
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.

src/main/java/com/example/constaia/ConstaiaModels.java
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.

src/main/java/com/example/constaia/ConstaiaException.java
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; }
}
src/main/java/com/example/constaia/ConstaiaClient.java
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.

src/main/java/com/example/constaia/DocumentVerificationController.java
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.

src/main/java/com/example/constaia/ConstaiaWebhookVerifier.java
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;
    }
}
src/main/java/com/example/constaia/ConstaiaWebhookController.java
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
FicheroVeredictoMotivo
dni_valid.jpgvalid"Vigente hasta el 12/03/2031."
dni_expired.jpginvalidnot_expired con severidad error: "Caducado el 15/06/2020."
blurry.jpgreviewlow_quality con severidad warning; avisos blurry y low_quality
dni_valid.jpg con full_name=Juan Pérezinvalidholder 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=20MB para no reenviar ficheros que la API rechazará.
  • Una Idempotency-Key por 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 202 y los análisis async; deduplica por webhook-id y responde rápido.
  • Revisa storage y keep_results en Almacenamiento y privacidad.

Siguientes pasos

En esta página