Constaia
Integrations

Spring Boot

Validate documents with Constaia from Spring Boot 3 and Java 21 using RestClient multipart, records, error handling and HMAC-verified webhooks.

Esta página ainda não está traduzida para o seu idioma. Mostramos a versão em inglês.

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

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

With Maven, the equivalent dependency is org.springframework.boot:spring-boot-starter-web.

Configuration

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_...

The key stays on the server; never send it to a frontend or a mobile app.

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));
        // 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.

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) {
    }
}

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.

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 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.

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;
    }

    // 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.

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 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
FileVerdictReason
dni_valid.jpgvalid"Valid until 12/03/2031."
dni_expired.jpginvalidnot_expired with severity error
blurry.jpgreviewlow_quality with severity warning; warnings blurry and low_quality
dni_valid.jpg with full_name=Juan Pérezinvalidholder 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=20MB so you don't forward files the API will reject.
  • One Idempotency-Key per 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 202 responses and async analyses; dedupe on webhook-id and answer fast.
  • Review storage and keep_results in Storage and privacy.

Next steps

Nesta página