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
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.
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.
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.
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.
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| 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. - Límite de 20 MB antes de llamar a la API.
- 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), por ejemplo con un
Semaphorede coroutines (Límites). - Webhook registrado para los
202y los análisisasync; deduplica porwebhook-idy responde rápido. - Revisa
storageykeep_resultsen Almacenamiento y privacidad.
Siguientes pasos
Spring Boot
Valida documentos con Constaia desde Spring Boot 3 y Java 21 usando RestClient multipart, records, manejo de errores y webhooks HMAC verificados.
.NET
Integra Constaia en ASP.NET Core 8+ con minimal API, IHttpClientFactory, MultipartFormDataContent, System.Text.Json y webhooks HMAC verificados.