Ktor
Validate documents with Constaia in Kotlin and Ktor 3, receiving multipart on the server, sending it with the Ktor client and verifying HMAC webhooks.
This guide uses Ktor on both sides: the server receives the user's file with receiveMultipart() and the Ktor
client forwards it to Constaia with submitFormWithBinaryData. Responses are decoded with kotlinx.serialization and
the webhook is verified with HMAC-SHA256.
There is no official Kotlin SDK: 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 the kotlin generator of openapi-generator).
Requirements
- JDK 17 or later, Kotlin 2.x and Ktor 3.x.
- A test key
ck_test_…from the dashboard (see Authentication). - The
whsec_…secret of a webhook endpoint (see Webhooks).
Installation
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")
}Use the latest Ktor 3.x and Kotlin 2.x versions; the io.ktor.plugin plugin pins the versions of the Ktor
modules.
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...The key stays on the server. Never ship it in an Android app or in client-side Kotlin Multiplatform code.
Models
Data classes with the part of the analysis you use and @SerialName for the snake_case names.
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)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.
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) {
// A synchronous analysis can take up to 30 s before returning 202.
requestTimeoutMillis = 60_000
connectTimeoutMillis = 10_000
}
}
/** status "completed" (HTTP 200) or "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,
)
}
}Server: receiving the document and the webhook
receiveMultipart() reads the user's file; formFieldLimit raises the per-part limit to just over 20 MB. Keep
originalFileName: in test mode the result depends on the name. Your backend decides expect and checks; don't
take them as-is from the client.
The webhook reads the raw body with call.receiveText() and verifies the signature before decoding it.
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("CONSTAIA_API_KEY is missing"))
val webhookSecret = System.getenv("CONSTAIA_WEBHOOK_SECRET") ?: error("CONSTAIA_WEBHOOK_SECRET is missing")
embeddedServer(Netty, port = 8080) {
routing {
post("/api/verify") {
// Add your authentication here: every analysis spends credits.
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, "The document is missing.")
if (data.size > MAX_BYTES) return@post call.respondError(HttpStatusCode.PayloadTooLarge, "Max 20 MB.")
val options = AnalyzeOptions(
expect = listOf("es_dni"),
checks = Checks(notExpired = true, holder = fullName?.takeIf { it.isNotBlank() }?.let { Holder(it) }),
storage = "none",
language = "en",
)
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 ?: "Invalid document.")
} else {
call.respondError(HttpStatusCode.BadGateway, "The document could not be verified. Please try again.")
}
}
if (analysis.status != "completed") {
// 202: the result will arrive by webhook (or call 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 is stable across retries: dedupe on it in your database.
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)
// Queue heavy work and answer within 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 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["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 AnalyzeOptions the API
answers 202 right away. analysis.review_required is sent in addition to analysis.completed when the verdict is
review.
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| File | Verdict | Reason |
|---|---|---|
dni_valid.jpg | valid | "Valid until 12/03/2031." |
dni_expired.jpg | invalid | not_expired with severity error |
blurry.jpg | review | low_quality with severity warning; warnings blurry and low_quality |
dni_valid.jpg with full_name=Juan Pérez | invalid | holder 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. - A 20 MB limit before calling the API.
- One
Idempotency-Keyper logical operation, reused on retries (Idempotency). - Limit concurrency towards Constaia (2 req/s per key on the free plan, 10 on paid), for example with a coroutines
Semaphore(Rate limits). - A webhook registered for
202responses andasyncanalyses; dedupe onwebhook-idand answer fast. - Review
storageandkeep_resultsin Storage and privacy.
Next steps
Spring Boot
Validate documents with Constaia from Spring Boot 3 and Java 21 using RestClient multipart, records, error handling and HMAC-verified webhooks.
.NET
Integrate Constaia into ASP.NET Core 8+ with a minimal API, IHttpClientFactory, MultipartFormDataContent, System.Text.Json and verified HMAC webhooks.