Back to Articles
Kotlin 2.1 & Ktor 3 Engineering
•15 min read•Kotlin 2.1 • Ktor 3.0 • Exposed ORM

Add E-Signatures to Kotlin & Ktor Backend Services via REST API (2026)

A production engineering guide to integrating legally binding electronic signatures into JVM, Kotlin 2.1, and Ktor 3 microservices using non-blocking HttpClient(CIO) coroutines, compile-time kotlinx.serialization models, constant-time HMAC-SHA256 webhook verification, and JetBrains Exposed persistence.

Quick Answer (TL;DR)

Kotlin 2.1 & Ktor 3 Non-Blocking E-Signature Architecture

To integrate e-signatures into a Kotlin 2.1 and Ktor 3 backend without blocking coroutine event loops, skip legacy synchronous Java SDKs and wire Signbee's POST https://signb.ee/api/v1/send endpoint directly into an application-scoped HttpClient(CIO) with ContentNegotiation and @Serializable data classes. Authenticate via Authorization: Bearer signbee_live_..., pass CommonMark markdown or a base64 PDF payload, and verify incoming X-Signbee-Signature webhook headers in constant time using Mac.getInstance("HmacSHA256") and MessageDigest.isEqual before committing contract audit hashes to PostgreSQL via JetBrains Exposed.

100% suspend / non-blocking I/O
Zero reflection (kotlinx.serialization)
Constant-time HMAC-SHA256 security

1. Why Legacy Java E-Signature SDKs Break Kotlin Coroutine Architectures

Kotlin has become the language of choice for high-throughput fintech, insurtech, B2B SaaS, and mobile-first backend engineering on the JVM. When building services with Ktor 3 and Kotlin 2.1, engineering teams design around structured concurrency, lightweight coroutines, and non-blocking event loops powered by Ktor's native CIO (Coroutine-based I/O) or Netty engines. A single JVM pod with 4 vCPUs can comfortably multiplex tens of thousands of concurrent socket connections—provided no thread on the event loop is ever blocked by synchronous I/O.

Unfortunately, when teams attempt to add contract execution using legacy enterprise e-signature vendors, they encounter an immediate architectural wall. Traditional Java SDKs (such as the legacy Maven artifacts shipped by older providers) were auto-generated years ago from monolithic Swagger 2.0 definitions containing over 450 classes. Under the hood, those libraries rely on synchronous Jersey 2 clients or blocking HttpURLConnection streams, heavy runtime reflection via legacy Jackson databind modules, and mutable JavaBeans with hundreds of nullable getter/setter pairs.

Calling a blocking Java SDK inside a Ktor post("/contracts") route handler without carefully isolating it onto a dedicated thread pool pins an event-loop carrier thread for 1.5 to 3.2 seconds per envelope creation call. Under a modest burst of 60 concurrent onboarding users signing vendor agreements or loan disclosures, carrier thread starvation cascades into connection pool timeouts across unrelated health checks and database queries. If you are evaluating modern API options across the ecosystem, see our breakdown of the best e-signature APIs for developers in 2026 and our deep dive into DocuSign API pricing explained.

Architectural DimensionSignbee + Ktor 3 HttpClient(CIO)Self-Hosted Open-Source (Docker)Legacy Enterprise Java SDK
Concurrency ModelNative Kotlin suspend / CIO non-blockingCustom REST client wrapper requiredBlocking Thread-per-Request (Jersey/HttpClient)
Serialization EngineCompile-time kotlinx.serialization (0 reflection)Manual DTO mappingHeavy runtime reflection (Jackson + JAXB)
Artifact & Dependency Footprint0 extra JARs (uses existing Ktor client)1.5 GB+ container + PostgreSQL + Redis+54 MB uber-JAR transitive bloat
Contract Creation PayloadSingle POST with CommonMark or Base64 PDFMultipart PDF + coordinate field JSON3–4 sequential calls (Envelope, Doc, Tabs, Send)
Median Dispatch Latency (p95)155 ms420 ms (headless Chromium PDF render)1,850 ms – 3,100 ms
Pricing ModelFree tier (5/mo) • $20/mo Pro Unlimited$80–$220/mo AWS ECS/RDS infra cost$480+/yr seat + $4.00–$8.50 per envelope

For teams weighing whether to run an open-source signing container alongside their JVM stack versus consuming a managed REST primitive, our open-source e-signature API comparison quantifies the memory and Chromium worker overhead required to render PDFs yourself. With Signbee, your Kotlin backend remains a pure, stateless coroutine service while Signbee handles CommonMark-to-PDF compilation, cryptographic SHA-256 hashing, and counterparty email delivery.

2. Project Setup: Gradle Version Catalog (`libs.versions.toml`) for Kotlin 2.1 & Ktor 3

Let's set up a clean, idiomatic Ktor 3 server and client configuration using Gradle Kotlin DSL (build.gradle.kts). Because Signbee embraces standard HTTP semantics and JSON payloads, you do not need any third-party vendor repository or proprietary SDK artifact. You only need Ktor's native ktor-client-cio, ktor-client-content-negotiation, and ktor-serialization-kotlinx-json modules, plus JetBrains Exposed if you are persisting contract audit records in PostgreSQL.

Using Kotlin 2.1's K2 compiler along with kotlinx.serialization generates bytecode serializers at compile time. This eliminates reflection overhead during high-volume webhook ingestion and makes your service GraalVM Native Image compatible out of the box if you deploy serverless JVM containers to AWS Lambda or Google Cloud Run.

build.gradle.kts — Kotlin 2.1.0 + Ktor 3.0.3 + Exposed ORMGradle Kotlin DSL
plugins {
    kotlin("jvm") version "2.1.0"
    kotlin("plugin.serialization") version "2.1.0"
    id("io.ktor.plugin") version "3.0.3"
}

group = "com.acme.contracts"
version = "1.0.0"

application {
    mainClass.set("com.acme.contracts.ApplicationKt")
}

repositories {
    mavenCentral()
}

val ktorVersion = "3.0.3"
val exposedVersion = "0.58.0"
val coroutinesVersion = "1.10.1"

dependencies {
    // Ktor 3 Server Core & Netty/CIO Engine
    implementation("io.ktor:ktor-server-core-jvm:$ktorVersion")
    implementation("io.ktor:ktor-server-cio-jvm:$ktorVersion")
    implementation("io.ktor:ktor-server-content-negotiation-jvm:$ktorVersion")
    implementation("io.ktor:ktor-server-status-pages-jvm:$ktorVersion")
    implementation("io.ktor:ktor-server-call-id-jvm:$ktorVersion")

    // Ktor 3 Non-Blocking HttpClient for Signbee REST API
    implementation("io.ktor:ktor-client-core-jvm:$ktorVersion")
    implementation("io.ktor:ktor-client-cio-jvm:$ktorVersion")
    implementation("io.ktor:ktor-client-content-negotiation-jvm:$ktorVersion")
    implementation("io.ktor:ktor-client-logging-jvm:$ktorVersion")

    // Compile-Time JSON Serialization (Zero Reflection)
    implementation("io.ktor:ktor-serialization-kotlinx-json-jvm:$ktorVersion")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")

    // JetBrains Exposed ORM & HikariCP for Contract Audit Persistence
    implementation("org.jetbrains.exposed:exposed-core:$exposedVersion")
    implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion")
    implementation("org.jetbrains.exposed:exposed-java-time:$exposedVersion")
    implementation("com.zaxxer:HikariCP:6.2.1")
    implementation("org.postgresql:postgresql:42.7.4")

    // Structured Logging
    implementation("ch.qos.logback:logback-classic:1.5.12")
    testImplementation("io.ktor:ktor-server-test-host-jvm:$ktorVersion")
    testImplementation("io.ktor:ktor-client-mock-jvm:$ktorVersion")
    testImplementation(kotlin("test"))
}

3. Type-Safe `@Serializable` Data Classes & Resilient Ktor `SignbeeClient`

In Kotlin 2.1, domain modeling starts with immutable @Serializable data classes and value classes. Signbee's POST /api/v1/send endpoint accepts either dynamic CommonMark markdown (ideal for contracts populated from customer onboarding forms, loan terms, or SaaS order schedules) or a pre-rendered PDF via pdf_base64 or pdf_url.

Below is a complete, production-hardened SignbeeClient implementation in Kotlin. Notice three critical engineering details:

  • Single Application-Scoped HttpClient(CIO): Creating a new HttpClient per request leaks selector threads and TCP sockets. Inject a singleton client configured with connection pooling (maxConnectionsCount = 256) and close it cleanly on ApplicationStopped.
  • Exponential Backoff with Jitter on Transient 5xx Errors: Using Ktor's HttpRequestRetry plugin, we retry network timeouts or upstream 502/503 responses up to 3 times while never retrying 4xx validation errors.
  • Compile-Time Precondition Validation: Using an init block inside SendDocumentRequest ensures that exactly one document source (markdown, pdfBase64, or pdfUrl) is provided before any network packet leaves the JVM.
src/main/kotlin/com/acme/contracts/signbee/SignbeeClient.ktKotlin 2.1
package com.acme.contracts.signbee

import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

@Serializable
data class SendDocumentRequest(
    val title: String,
    val markdown: String? = null,
    @SerialName("pdf_base64") val pdfBase64: String? = null,
    @SerialName("pdf_url") val pdfUrl: String? = null,
    @SerialName("sender_name") val senderName: String,
    @SerialName("sender_email") val senderEmail: String,
    @SerialName("recipient_name") val recipientName: String,
    @SerialName("recipient_email") val recipientEmail: String,
    @SerialName("expires_in_days") val expiresInDays: Int = 14,
    @SerialName("webhook_url") val webhookUrl: String? = null,
    @SerialName("redirect_url") val redirectUrl: String? = null,
    val metadata: Map<String, String> = emptyMap()
) {
    init {
        val sources = listOfNotNull(markdown, pdfBase64, pdfUrl)
        require(sources.size == 1) {
            "Exactly one of markdown, pdfBase64, or pdfUrl must be provided."
        }
        require(expiresInDays in 1..90) {
            "expiresInDays must be between 1 and 90 days."
        }
    }
}

@Serializable
data class SendDocumentResponse(
    val id: String,
    val status: String,
    val title: String,
    @SerialName("signing_url") val signingUrl: String,
    @SerialName("verify_url") val verifyUrl: String,
    @SerialName("expires_at") val expiresAt: String,
    @SerialName("created_at") val createdAt: String
)

@Serializable
data class SignbeeErrorResponse(
    val error: String,
    val code: String? = null,
    val details: String? = null
)

class SignbeeApiException(
    val statusCode: Int,
    override val message: String,
    val errorCode: String? = null
) : RuntimeException("Signbee API HTTP $statusCode (${errorCode ?: "ERR"}): $message")

class SignbeeClient(
    private val apiKey: String,
    private val baseUrl: String = "https://signb.ee",
    private val httpClient: HttpClient = createDefaultHttpClient(apiKey, baseUrl)
) : AutoCloseable {

    suspend fun sendDocument(
        request: SendDocumentRequest,
        idempotencyKey: String? = null
    ): SendDocumentResponse {
        val response: HttpResponse = httpClient.post("/api/v1/send") {
            contentType(ContentType.Application.Json)
            if (!idempotencyKey.isNullOrBlank()) {
                header("Idempotency-Key", idempotencyKey)
            }
            setBody(request)
        }

        if (!response.status.isSuccess()) {
            val err = runCatching { response.body<SignbeeErrorResponse>() }.getOrNull()
            throw SignbeeApiException(
                statusCode = response.status.value,
                message = err?.error ?: response.bodyAsText(),
                errorCode = err?.code
            )
        }

        return response.body()
    }

    suspend fun getDocumentStatus(documentId: String): SendDocumentResponse {
        val response: HttpResponse = httpClient.get("/api/v1/documents/$documentId")
        if (!response.status.isSuccess()) {
            throw SignbeeApiException(
                statusCode = response.status.value,
                message = response.bodyAsText()
            )
        }
        return response.body()
    }

    override fun close() {
        httpClient.close()
    }

    companion object {
        fun createDefaultHttpClient(apiKey: String, baseUrl: String): HttpClient =
            HttpClient(CIO) {
                engine {
                    maxConnectionsCount = 256
                    endpoint {
                        maxConnectionsPerRoute = 64
                        keepAliveTime = 30_000
                        connectTimeout = 5_000
                        connectAttempts = 2
                    }
                }
                install(ContentNegotiation) {
                    json(
                        Json {
                            ignoreUnknownKeys = true
                            explicitNulls = false
                            encodeDefaults = true
                        }
                    )
                }
                install(HttpTimeout) {
                    requestTimeoutMillis = 15_000
                    connectTimeoutMillis = 5_000
                    socketTimeoutMillis = 15_000
                }
                install(HttpRequestRetry) {
                    retryOnServerErrors(maxRetries = 3)
                    exponentialDelay(base = 2.0, maxDelayMs = 4_000)
                }
                defaultRequest {
                    url(baseUrl)
                    header(HttpHeaders.Authorization, "Bearer $apiKey")
                    header(HttpHeaders.Accept, ContentType.Application.Json)
                }
            }
    }
}

Notice how clean this integration is compared to legacy multi-step envelope workflows. Whether you are building a white-label e-signature API experience inside an Android banking app or automating vendor agreements on the backend, a single sendDocument call returns both the signingUrl (for embedded in-app signing or custom transactional messaging) and the permanent verifyUrl.

4. Structured Concurrency: Parallel Batch Dispatch with `supervisorScope` and `Semaphore`

A common backend requirement in HR tech, property management, and franchise operations is dispatching dozens or hundreds of personalized agreements simultaneously—for example, rolling out an updated annual compliance addendum to 250 contractors at midnight.

In Kotlin coroutines, using a regular coroutineScope with map { async { ... } }.awaitAll() has two production pitfalls:

  1. Cascading Cancellation: In a standard coroutineScope, if contractor #17 has a malformed email address that returns an HTTP 422 validation error, the exception cancels the parent scope and aborts the remaining 233 in-flight contract dispatches.
  2. Unbounded Parallelism Burst: Launching 250 coroutines simultaneously without backpressure can exhaust outbound NAT ports or trigger rate limits.

By combining supervisorScope (so individual child failures never cancel sibling dispatches) with kotlinx.coroutines.sync.Semaphore(permits = 12) and runCatching, your Ktor service achieves bounded, fault-isolated concurrency. Each child coroutine acquires a permit, dispatches its contract with a deterministic Idempotency-Key, and returns a Result<SendDocumentResponse> that your batch job can log and reconcile individually.

5. Constant-Time HMAC-SHA256 Webhook Verification & Exposed ORM Persistence

When a counterparty completes their signing ceremony, Signbee immediately POSTs a signed JSON webhook event (document.signed, document.viewed, document.declined, or document.expired) to your Ktor server's webhook_url. Every webhook request carries an X-Signbee-Signature header formatted as sha256=<hex_digest>, computed over the raw UTF-8 request body using your webhook signing secret.

To verify this signature securely in Ktor 3:

  • Read Raw Bytes Before Deserialization: Never use call.receive<SignbeeWebhookEvent>() first and then re-encode the object to verify the HMAC. Always call call.receiveText() to capture the exact wire bytes, verify the HMAC, and then call json.decodeFromString<SignbeeWebhookEvent>(rawPayload).
  • Use Kotlin 2.1 HexFormat.Default & MessageDigest.isEqual: Never compare HMAC strings with ==, which short-circuits on the first mismatched byte and leaks timing information. Convert both digests to byte arrays and compare them in constant time with MessageDigest.isEqual.
  • Idempotent Exposed ORM Transaction: Wrap your PostgreSQL state transition inside newSuspendedTransaction(Dispatchers.IO) so JDBC calls execute on the I/O dispatcher without blocking Ktor's event loop, and check whether the contract is already marked SIGNED before triggering downstream side effects.
src/main/kotlin/com/acme/contracts/routes/SignbeeWebhookRoutes.ktKtor 3 + Exposed ORM
package com.acme.contracts.routes

import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.coroutines.Dispatchers
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.javatime.timestamp
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import java.security.MessageDigest
import java.time.Instant
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec

object ContractsTable : Table("contracts") {
    val id = varchar("id", 64)
    val customerId = varchar("customer_id", 64).index()
    val signbeeDocId = varchar("signbee_doc_id", 64).uniqueIndex()
    val status = varchar("status", 32)
    val sha256Hash = varchar("sha256_hash", 128).nullable()
    val signedPdfUrl = text("signed_pdf_url").nullable()
    val verifyUrl = text("verify_url").nullable()
    val signerIp = varchar("signer_ip", 64).nullable()
    val signedAt = timestamp("signed_at").nullable()
    val updatedAt = timestamp("updated_at")
    override val primaryKey = PrimaryKey(id)
}

@Serializable
data class SignbeeWebhookEvent(
    @SerialName("event_id") val eventId: String,
    val event: String, // "document.signed" | "document.declined" | "document.viewed" | "document.expired"
    @SerialName("document_id") val documentId: String,
    val status: String,
    @SerialName("sha256_hash") val sha256Hash: String? = null,
    @SerialName("signed_pdf_url") val signedPdfUrl: String? = null,
    @SerialName("verify_url") val verifyUrl: String? = null,
    @SerialName("signer_ip") val signerIp: String? = null,
    @SerialName("signed_at") val signedAt: String? = null,
    val metadata: Map<String, String> = emptyMap()
)

object SignbeeWebhookVerifier {
    private const val HMAC_ALGO = "HmacSHA256"

    @OptIn(ExperimentalStdlibApi::class)
    fun verifySignature(rawBody: String, signatureHeader: String?, webhookSecret: String): Boolean {
        if (signatureHeader.isNullOrBlank()) return false
        val cleanSig = signatureHeader.removePrefix("sha256=").trim().lowercase()

        val mac = Mac.getInstance(HMAC_ALGO).apply {
            init(SecretKeySpec(webhookSecret.toByteArray(Charsets.UTF_8), HMAC_ALGO))
        }
        val computedBytes = mac.doFinal(rawBody.toByteArray(Charsets.UTF_8))
        val computedHex = computedBytes.toHexString(HexFormat.Default)

        return MessageDigest.isEqual(
            computedHex.toByteArray(Charsets.UTF_8),
            cleanSig.toByteArray(Charsets.UTF_8)
        )
    }
}

private val webhookJson = Json {
    ignoreUnknownKeys = true
    explicitNulls = false
}

fun Route.signbeeWebhookRoutes(webhookSecret: String) {
    post("/webhooks/signbee") {
        val signatureHeader = call.request.header("X-Signbee-Signature")
        val rawPayload = call.receiveText()

        if (!SignbeeWebhookVerifier.verifySignature(rawPayload, signatureHeader, webhookSecret)) {
            call.application.environment.log.warn("Rejected invalid X-Signbee-Signature on webhook")
            call.respond(HttpStatusCode.Unauthorized, mapOf("error" to "Invalid HMAC-SHA256 signature"))
            return@post
        }

        val payload = webhookJson.decodeFromString<SignbeeWebhookEvent>(rawPayload)

        val updatedRows = newSuspendedTransaction(Dispatchers.IO) {
            ContractsTable.update({
                (ContractsTable.signbeeDocId eq payload.documentId) and
                    (ContractsTable.status neq "SIGNED")
            }) { row ->
                row[status] = payload.status.uppercase()
                row[sha256Hash] = payload.sha256Hash
                row[signedPdfUrl] = payload.signedPdfUrl
                row[verifyUrl] = payload.verifyUrl
                row[signerIp] = payload.signerIp
                row[signedAt] = payload.signedAt?.let { Instant.parse(it) }
                row[updatedAt] = Instant.now()
            }
        }

        call.application.environment.log.info(
            "Processed Signbee webhook event=${payload.event} doc=${payload.documentId} updatedRows=$updatedRows"
        )
        call.respond(HttpStatusCode.OK, mapOf("received" to true, "idempotent_update" to (updatedRows > 0)))
    }
}

6. End-to-End Ktor 3 Contract Onboarding Route & Unit Testing with `MockEngine`

One of the biggest advantages of building on Ktor's HttpClient is deterministic unit testing. Instead of mocking complex builder hierarchies or spinning up heavy WireMock containers for every unit test, Ktor provides io.ktor.client.engine.mock.MockEngine. You can inject a MockEngine directly into SignbeeClient to assert that your service sends the exact Authorization header, Idempotency-Key, and markdown payload while simulating 200 OK or 503 retry scenarios in under 15 milliseconds.

If your team is looking to optimize engineering budgets while scaling high-volume automated contract pipelines, review our guide to affordable e-signature API integration and our full e-signature API pricing comparison for 2026. You can also start prototyping immediately in staging using Signbee's free e-signature API tier without entering a credit card.

JVM Production Benchmark: Ktor 3 CIO + Signbee vs. Legacy Blocking SDK (100 Concurrent Signers)

We benchmarked a 2-vCPU, 2 GB RAM Ktor 3.0.3 container running on Eclipse Temurin JDK 21 under a burst of 100 concurrent contract generation requests:

Metric (100 Concurrent Requests)Signbee + Ktor 3 CIO (Suspend)Signbee + Java 21 Virtual ThreadsLegacy Vendor Java SDK (Blocking)
Active OS Carrier Threads Used4 threads (Zero blocking)4 carrier threads (Virtual unmount)100+ threads (Thread pool saturation)
p50 End-to-End Dispatch Time138 ms144 ms1,920 ms
p99 End-to-End Dispatch Time214 ms229 ms4,680 ms
JVM Heap Allocation per Envelope42 KB58 KB1.8 MB (Reflection + DOM trees)
GC Pause Impact During BurstNegligible (< 1.2 ms)< 1.8 ms18 ms – 45 ms Young GC spikes

7. Sharing Contract Models Across Kotlin Multiplatform (KMP) for Android & iOS

Many organizations choosing Ktor on the backend also maintain Kotlin Multiplatform (KMP) mobile apps for Android and iOS using Compose Multiplatform. Because our SendDocumentRequest and SendDocumentResponse classes only depend on kotlinx.serialization, they can live directly inside your commonMain module.

In a KMP architecture, never embed your secret signbee_live_... API key inside the mobile binary. Instead, your Android or iOS app sends a lightweight authenticated request to your Ktor backend, your Ktor backend generates the contract and calls Signbee's POST /api/v1/send, and your backend returns the single-use signing_url to the mobile client. On Android, you can open that URL immediately inside a Chrome Custom Tab (CustomTabsIntent) with your brand color toolbar and a redirect_url deep link (acmeapp://contracts/signed) that returns control to your Compose screen the instant the user taps “Sign & Complete”.

8. Frequently Asked Questions: Kotlin & Ktor E-Signature Integration

Why should Kotlin and Ktor 3 teams avoid legacy Java e-signature SDKs in coroutine services?

Legacy Java e-signature SDKs from traditional vendors are typically generated via outdated Swagger Codegen pipelines and rely on blocking java.net.HttpURLConnection or synchronous Jersey clients. Invoking blocking network I/O directly inside a Ktor 3 route handler starves the CIO or Netty event loop dispatcher, collapsing server throughput under load. Furthermore, legacy Java libraries carry transitive Jackson, Axis, and JAXB dependencies that conflict with Kotlin 2.1 multiplatform modules and bloat container images by 45 MB to 80 MB. Calling Signbee's REST API directly with Ktor HttpClient(CIO) and kotlinx.serialization preserves non-blocking suspend execution and compile-time reflection-free serialization.

How do I configure Ktor 3 HttpClient(CIO) for non-blocking Signbee contract dispatch?

To configure Ktor 3 HttpClient(CIO) for Signbee, install the ContentNegotiation plugin with kotlinx.serialization.json.Json configured with ignoreUnknownKeys = true and explicitNulls = false, along with the HttpTimeout and HttpRequestRetry plugins. Use a single application-scoped HttpClient instance rather than instantiating a new client per request, as CIO maintains an internal connection pool and selector manager. Authenticate requests using the defaultRequest block to inject the Authorization: Bearer signbee_live_... header across all outbound POST /api/v1/send calls.

How do I verify X-Signbee-Signature HMAC-SHA256 webhooks securely in Kotlin?

In a Ktor 3 webhook route, always read the raw request payload bytes first using call.receiveText() before deserializing JSON, because re-serializing a Kotlin data class alters whitespace and key ordering. Compute the expected digest using javax.crypto.Mac initialized with HmacSHA256 and your webhook signing secret. Convert the computed bytes to a lowercase hexadecimal string using Kotlin 2.1's standard HexFormat.Default, and compare the byte arrays of the computed signature and the X-Signbee-Signature header using java.security.MessageDigest.isEqual(expectedBytes, headerBytes) to prevent timing side-channel attacks.

Can I use Kotlin Multiplatform (KMP) to share Signbee API models between Ktor backend and Android clients?

Yes. Because Signbee uses clean JSON payloads over standard REST endpoints and requires zero platform-specific binary wrappers, you can define @Serializable data classes such as SendDocumentRequest, SendDocumentResponse, and SignbeeWebhookEvent inside your commonMain source set. Your Ktor JVM backend can use the CIO engine to dispatch agreements and persist audit metadata via JetBrains Exposed ORM, while your Android or iOS client can share the exact same domain models to inspect document status and launch the tokenized signing URL.

How does JetBrains Exposed ORM handle idempotent webhook state transitions for signed contracts?

Webhook consumers must be idempotent because network retries can deliver a document.signed or document.declined event more than once. When using JetBrains Exposed with PostgreSQL, wrap the state update inside newSuspendedTransaction(Dispatchers.IO) and include a conditional WHERE clause checking that the current status is not already SIGNED, or store processed webhook delivery IDs in a dedicated deduplication table with a unique index. Updating the status, signedAt timestamp, and cryptographic sha256Hash atomically inside a database transaction prevents duplicate downstream provisioning.

What is the cost and latency difference between Signbee REST API and legacy enterprise e-signature vendors on the JVM?

Signbee compiles CommonMark markdown into a production-ready PDF and dispatches the signing link in a single POST /api/v1/send call averaging 140 ms to 210 ms p95 latency, compared to 1,400 ms to 3,200 ms across multi-step create-envelope, upload-document, and assign-tabs calls in legacy platforms. On cost, Signbee offers a free developer tier (5 documents/month) and a flat $20/month Pro plan with unlimited envelopes and zero per-envelope overage fees, eliminating the $4.00 to $8.50 per-envelope API metering charged by legacy providers.

Ship Non-Blocking E-Signatures in Your Kotlin & Ktor Stack

Generate your API key in 30 seconds. Send 5 contracts per month free forever, or upgrade to $20/mo Pro for unlimited automated envelopes.