Constaia
Integraciones

Ktor

Valida documentos con Constaia en Kotlin y Ktor 3, recibiendo multipart en el servidor, enviándolo con el cliente Ktor y verificando webhooks HMAC.

Esta guía usa Ktor en los dos lados: el servidor recibe el fichero del usuario con receiveMultipart() y el cliente Ktor lo reenvía a Constaia con submitFormWithBinaryData. Las respuestas se decodifican con kotlinx.serialization y el webhook se verifica con HMAC-SHA256.

No hay SDK oficial para Kotlin: 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 el generador kotlin de openapi-generator).

Requisitos

  • JDK 17 o superior, Kotlin 2.x y Ktor 3.x.
  • Una clave de test ck_test_… del panel (ver Autenticación).
  • El secreto whsec_… de un endpoint de webhook (ver Webhooks).

Instalación

build.gradle.kts
plugins {
    kotlin("jvm") version "2.2.20"
    kotlin("plugin.serialization") version "2.2.20"
    id("io.ktor.plugin") version "3.3.0"
}

application {
    mainClass.set("com.example.ApplicationKt")
}

dependencies {
    implementation("io.ktor:ktor-server-core")
    implementation("io.ktor:ktor-server-netty")
    implementation("io.ktor:ktor-client-core")
    implementation("io.ktor:ktor-client-cio")
    implementation("io.ktor:ktor-client-content-negotiation")
    implementation("io.ktor:ktor-serialization-kotlinx-json")
    implementation("ch.qos.logback:logback-classic:1.5.18")
}

Usa las últimas versiones 3.x de Ktor y 2.x de Kotlin; el plugin io.ktor.plugin fija las versiones de los módulos de Ktor.

.env
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...

La clave se queda en el servidor. Nunca la incluyas en una app Android ni en Kotlin Multiplatform del lado cliente.

Modelos

Data classes con la parte del análisis que usas y @SerialName para los nombres en snake_case.

src/main/kotlin/com/example/Models.kt
package com.example

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement

@Serializable
data class Analysis(
    val id: String,
    val status: String,
    val livemode: Boolean = false,
    @SerialName("created_at") val createdAt: String? = null,
    val document: DocumentInfo? = null,
    val verdict: Verdict? = null,
    val fields: Map<String, Field> = emptyMap(),
    val warnings: List<String> = emptyList(),
    val error: AnalysisError? = null,
)

@Serializable
data class DocumentInfo(val type: String, val label: String? = null, val confidence: Double? = null)

@Serializable
data class Verdict(
    val expected: List<String> = emptyList(),
    val match: Boolean = false,
    val status: String,
    val reasons: List<Reason> = emptyList(),
)

@Serializable
data class Reason(val code: String, val severity: String, val message: String)

@Serializable
data class Field(val value: JsonElement? = null, val confidence: Double? = null, val validated: Boolean? = null)

@Serializable
data class AnalysisError(val code: String, val message: String? = null)

@Serializable
data class AnalyzeOptions(
    val expect: List<String>,
    val checks: Checks? = null,
    val storage: String? = null,
    val async: Boolean? = null,
    val language: String? = null,
    val metadata: Map<String, String>? = null,
)

@Serializable
data class Checks(
    @SerialName("not_expired") val notExpired: Boolean? = null,
    val holder: Holder? = null,
)

@Serializable
data class Holder(@SerialName("full_name") val fullName: String? = null)

@Serializable
data class ApiErrorBody(
    val type: String? = null,
    val code: String? = null,
    val message: String? = null,
    val param: String? = null,
    @SerialName("request_id") val requestId: String? = null,
)

@Serializable
data class ErrorEnvelope(val error: ApiErrorBody)

@Serializable
data class WebhookEvent(val type: String, @SerialName("created_at") val createdAt: String? = null, val data: JsonElement)

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.

src/main/kotlin/com/example/ConstaiaClient.kt
package com.example

import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.bearerAuth
import io.ktor.client.request.forms.formData
import io.ktor.client.request.forms.submitFormWithBinaryData
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.http.Headers
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.json
import java.util.UUID
import kotlinx.coroutines.delay
import kotlinx.serialization.json.Json

class ConstaiaException(
    val status: Int,
    val type: String?,
    val code: String?,
    message: String,
    val param: String?,
    val requestId: String?,
    val retryAfterSeconds: Long,
) : Exception(message)

val constaiaJson = Json {
    ignoreUnknownKeys = true
    explicitNulls = false
}

class ConstaiaClient(private val apiKey: String) {
    private val baseUrl = "https://api.constaia.com"
    private val maxRetries = 2

    private val http = HttpClient(CIO) {
        install(ContentNegotiation) { json(constaiaJson) }
        install(HttpTimeout) {
            // El análisis síncrono puede tardar hasta 30 s antes de devolver 202.
            requestTimeoutMillis = 60_000
            connectTimeoutMillis = 10_000
        }
    }

    /** status "completed" (HTTP 200) o "queued"/"processing" (HTTP 202). */
    suspend fun analyze(bytes: ByteArray, filename: String, contentType: String?, options: AnalyzeOptions): Analysis {
        val optionsJson = constaiaJson.encodeToString(AnalyzeOptions.serializer(), options)
        val idempotencyKey = UUID.randomUUID().toString()
        val safeName = filename.replace("\"", "")

        var attempt = 0
        while (true) {
            val response = http.submitFormWithBinaryData(
                url = "$baseUrl/v1/analyze",
                formData = formData {
                    append("file", bytes, Headers.build {
                        append(HttpHeaders.ContentType, contentType ?: "application/octet-stream")
                        append(HttpHeaders.ContentDisposition, "filename=\"$safeName\"")
                    })
                    append("options", optionsJson)
                },
            ) {
                bearerAuth(apiKey)
                header("Idempotency-Key", idempotencyKey)
            }

            if (response.status.isSuccess()) return response.body()

            val error = toException(response)
            if (error.status == HttpStatusCode.TooManyRequests.value && attempt < maxRetries) {
                attempt++
                delay(error.retryAfterSeconds * 1_000)
                continue
            }
            throw error
        }
    }

    suspend fun getAnalysis(id: String): Analysis {
        val response = http.get("$baseUrl/v1/analyses/$id") { bearerAuth(apiKey) }
        if (!response.status.isSuccess()) throw toException(response)
        return response.body()
    }

    private suspend fun toException(response: HttpResponse): ConstaiaException {
        val body = runCatching { constaiaJson.decodeFromString(ErrorEnvelope.serializer(), response.bodyAsText()).error }
            .getOrNull()
        return ConstaiaException(
            status = response.status.value,
            type = body?.type,
            code = body?.code,
            message = body?.message ?: "HTTP ${response.status.value}",
            param = body?.param,
            requestId = body?.requestId ?: response.headers["X-Request-Id"],
            retryAfterSeconds = response.headers[HttpHeaders.RetryAfter]?.toLongOrNull() ?: 1,
        )
    }
}

Servidor: recibir el documento y el webhook

receiveMultipart() lee el fichero del usuario; formFieldLimit sube el límite por parte a algo más de 20 MB. Conserva originalFileName: en modo test el resultado depende del nombre. Tu backend decide expect y checks; no los aceptes tal cual del cliente.

El webhook lee el cuerpo crudo con call.receiveText() y verifica la firma antes de decodificarlo.

src/main/kotlin/com/example/Application.kt
package com.example

import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.content.PartData
import io.ktor.http.content.forEachPart
import io.ktor.server.application.ApplicationCall
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.request.receiveMultipart
import io.ktor.server.request.receiveText
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import io.ktor.utils.io.readRemaining
import java.security.MessageDigest
import java.time.Instant
import java.util.Base64
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import kotlinx.io.readByteArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import kotlinx.serialization.json.add
import org.slf4j.LoggerFactory

private const val MAX_BYTES = 20L * 1024 * 1024
private val log = LoggerFactory.getLogger("constaia")

fun main() {
    val constaia = ConstaiaClient(System.getenv("CONSTAIA_API_KEY") ?: error("falta CONSTAIA_API_KEY"))
    val webhookSecret = System.getenv("CONSTAIA_WEBHOOK_SECRET") ?: error("falta CONSTAIA_WEBHOOK_SECRET")

    embeddedServer(Netty, port = 8080) {
        routing {
            post("/api/verify") {
                // Añade aquí tu autenticación: cada análisis consume créditos.
                var bytes: ByteArray? = null
                var filename = "document"
                var contentType: String? = null
                var fullName: String? = null

                call.receiveMultipart(formFieldLimit = MAX_BYTES + 1).forEachPart { part ->
                    when (part) {
                        is PartData.FileItem -> if (part.name == "file") {
                            filename = part.originalFileName ?: filename
                            contentType = part.contentType?.toString()
                            bytes = part.provider().readRemaining().readByteArray()
                        }
                        is PartData.FormItem -> if (part.name == "full_name") fullName = part.value
                        else -> {}
                    }
                    part.dispose()
                }

                val data = bytes
                if (data == null || data.isEmpty()) return@post call.respondError(HttpStatusCode.BadRequest, "Falta el documento.")
                if (data.size > MAX_BYTES) return@post call.respondError(HttpStatusCode.PayloadTooLarge, "Máximo 20 MB.")

                val options = AnalyzeOptions(
                    expect = listOf("es_dni"),
                    checks = Checks(notExpired = true, holder = fullName?.takeIf { it.isNotBlank() }?.let { Holder(it) }),
                    storage = "none",
                    language = "es",
                )

                val analysis = try {
                    constaia.analyze(data, filename, contentType, options)
                } catch (e: ConstaiaException) {
                    log.warn("Constaia {} {} request_id={}", e.status, e.code, e.requestId)
                    return@post if (e.type == "invalid_request") {
                        call.respondError(HttpStatusCode.UnprocessableEntity, e.message ?: "Documento no válido.")
                    } else {
                        call.respondError(HttpStatusCode.BadGateway, "No se pudo verificar el documento. Inténtalo de nuevo.")
                    }
                }

                if (analysis.status != "completed") {
                    // 202: el resultado llegará por webhook (o consulta getAnalysis).
                    return@post call.respondJson(HttpStatusCode.Accepted, buildJsonObject {
                        put("id", analysis.id)
                        put("status", analysis.status)
                    })
                }

                call.respondJson(HttpStatusCode.OK, buildJsonObject {
                    put("id", analysis.id)
                    put("status", analysis.verdict?.status)
                    putJsonArray("reasons") { analysis.verdict?.reasons?.forEach { add(it.message) } }
                    putJsonArray("warnings") { analysis.warnings.forEach { add(it) } }
                })
            }

            post("/webhooks/constaia") {
                val raw = call.receiveText()
                val headers = call.request.headers
                val valid = verifyWebhook(
                    secret = webhookSecret,
                    id = headers["webhook-id"],
                    timestamp = headers["webhook-timestamp"],
                    signatures = headers["webhook-signature"],
                    body = raw,
                )
                if (!valid) return@post call.respond(HttpStatusCode.BadRequest)

                val event = constaiaJson.decodeFromString(WebhookEvent.serializer(), raw)
                // webhook-id es estable entre reintentos: deduplica con él en tu base de datos.
                when (event.type) {
                    "analysis.completed", "analysis.review_required", "analysis.failed" -> {
                        val analysis = constaiaJson.decodeFromJsonElement(Analysis.serializer(), event.data)
                        log.info("{} {} {} {}", headers["webhook-id"], event.type, analysis.id, analysis.verdict?.status)
                        // Encola el trabajo pesado y responde en menos de 15 s.
                    }
                    else -> {} // batch.completed, credits.low, test…
                }
                call.respond(HttpStatusCode.NoContent)
            }
        }
    }.start(wait = true)
}

fun verifyWebhook(secret: String, id: String?, timestamp: String?, signatures: String?, body: String): Boolean {
    if (id == null || timestamp == null || signatures == null) return false
    val ts = timestamp.toLongOrNull() ?: return false
    if (kotlin.math.abs(Instant.now().epochSecond - ts) > 300) return false

    val key = Base64.getDecoder().decode(secret.removePrefix("whsec_"))
    val mac = Mac.getInstance("HmacSHA256").apply { init(SecretKeySpec(key, "HmacSHA256")) }
    val expected = mac.doFinal("$id.$timestamp.$body".toByteArray(Charsets.UTF_8))

    return signatures.split(" ").any { part ->
        val (version, sig) = part.split(",", limit = 2).let { it.getOrNull(0) to it.getOrNull(1) }
        version == "v1" && sig != null &&
            runCatching { MessageDigest.isEqual(Base64.getDecoder().decode(sig), expected) }.getOrDefault(false)
    }
}

private suspend fun ApplicationCall.respondJson(status: HttpStatusCode, body: JsonElement) =
    respondText(body.toString(), ContentType.Application.Json, status)

private suspend fun ApplicationCall.respondError(status: HttpStatusCode, message: String) =
    respondJson(status, buildJsonObject { putJsonObject("error") { put("message", JsonPrimitive(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["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 AnalyzeOptions la API responde 202 al momento. analysis.review_required llega además de analysis.completed cuando el veredicto es review.

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.
  • Límite de 20 MB antes de llamar a la API.
  • 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), por ejemplo con un Semaphore de coroutines (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