Constaia
Integrations

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

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

Use the latest Ktor 3.x and Kotlin 2.x versions; the io.ktor.plugin plugin pins the versions of the Ktor modules.

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

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)

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.

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

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("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
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.
  • A 20 MB limit before calling the API.
  • 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), for example with a coroutines Semaphore (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

On this page