← Back to blog

Send Email with Kotlin using the Rovela API

Learn how to send transactional email from Kotlin using HttpURLConnection, OkHttp, or the Ktor client. Includes retry logic with idempotency keys, coroutine-based batch sending with Semaphore, and webhook verification for Ktor server and Spring Boot.

Updated November 3, 2026
Send Email with Kotlin using the Rovela API

This guide covers sending transactional email from Kotlin using the Rovela API. It includes the built-in HttpURLConnection, OkHttp, Ktor client, retry logic with idempotency keys, coroutine-based batch sending, and webhook verification for Ktor server and Spring Boot.

Prerequisites

Set your API key as an environment variable:

export ROVELA_API_KEY="re_..."

Send with HttpURLConnection

No extra dependencies — just the JDK standard library:

import java.net.HttpURLConnection
import java.net.URL

fun sendEmail(to: String, subject: String, html: String): String {
    val apiKey = System.getenv("ROVELA_API_KEY")
    val url = URL("https://api.rovela.dev/emails")
    val conn = url.openConnection() as HttpURLConnection
    conn.requestMethod = "POST"
    conn.setRequestProperty("Authorization", "Bearer $apiKey")
    conn.setRequestProperty("Content-Type", "application/json")
    conn.doOutput = true

    val body = """{"from":"Acme <noreply@yourdomain.com>","to":"$to","subject":"$subject","html":"$html"}"""
    conn.outputStream.use { it.write(body.toByteArray()) }

    return conn.inputStream.use { it.readBytes().decodeToString() }
}

fun main() {
    val result = sendEmail("user@example.com", "Welcome aboard", "<p>Thanks for signing up!</p>")
    println(result)
}

Send with OkHttp

OkHttp is a widely-used HTTP client with connection pooling and a clean fluent API.

import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody

val client = OkHttpClient()
val JSON = "application/json".toMediaType()

fun sendEmail(to: String, subject: String, html: String): String {
    val apiKey = System.getenv("ROVELA_API_KEY")
    val body = """
        {
          "from": "Acme <noreply@yourdomain.com>",
          "to": "$to",
          "subject": "$subject",
          "html": "$html"
        }
    """.trimIndent().toRequestBody(JSON)

    val request = Request.Builder()
        .url("https://api.rovela.dev/emails")
        .addHeader("Authorization", "Bearer $apiKey")
        .post(body)
        .build()

    client.newCall(request).execute().use { response ->
        check(response.isSuccessful) { "HTTP ${response.code}" }
        return response.body!!.string()
    }
}

Send with Ktor HttpClient

In a Ktor or coroutine-based project, use the Ktor HTTP client for non-blocking requests:

import io.ktor.client.*
import io.ktor.client.engine.cio.*
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.Serializable

@Serializable
data class SendEmailRequest(
    val from: String,
    val to: String,
    val subject: String,
    val html: String,
)

val httpClient = HttpClient(CIO) {
    install(ContentNegotiation) { json() }
}

suspend fun sendEmail(req: SendEmailRequest): HttpResponse {
    return httpClient.post("https://api.rovela.dev/emails") {
        header(HttpHeaders.Authorization, "Bearer ${System.getenv("ROVELA_API_KEY")}")
        contentType(ContentType.Application.Json)
        setBody(req)
    }
}

Retry with idempotency key

Generate the idempotency key once before the retry loop and pass the same value on every attempt. This prevents duplicate delivery when the network drops after the server accepts the request.

import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.UUID
import kotlin.math.pow

val JSON = "application/json".toMediaType()
val client = OkHttpClient()

fun sendWithRetry(payload: String, maxRetries: Int = 4): String {
    val idempotencyKey = UUID.randomUUID().toString() // generated once, reused on every retry

    for (attempt in 0 until maxRetries) {
        val request = Request.Builder()
            .url("https://api.rovela.dev/emails")
            .addHeader("Authorization", "Bearer ${System.getenv("ROVELA_API_KEY")}")
            .addHeader("Idempotency-Key", idempotencyKey)
            .post(payload.toRequestBody(JSON))
            .build()

        client.newCall(request).execute().use { response ->
            if (response.code != 429) {
                check(response.isSuccessful) { "HTTP ${response.code}" }
                return response.body!!.string()
            }
            val retryAfter = response.header("Retry-After")?.toLongOrNull()
            val delayMs = retryAfter?.times(1000) ?: (2.0.pow(attempt) * 1000).toLong()
            Thread.sleep(delayMs)
        }
    }
    throw RuntimeException("Rate limit exceeded after $maxRetries attempts")
}

Batch sending with coroutines

Use a Semaphore from kotlinx.coroutines to cap concurrency when sending to many recipients:

import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit

data class Recipient(val email: String, val subject: String, val html: String)

suspend fun sendBatch(recipients: List<Recipient>) = coroutineScope {
    val semaphore = Semaphore(5) // max 5 concurrent requests
    val httpClient = HttpClient(CIO)

    recipients.map { r ->
        async {
            semaphore.withPermit {
                val response = httpClient.post("https://api.rovela.dev/emails") {
                    header(HttpHeaders.Authorization, "Bearer ${System.getenv("ROVELA_API_KEY")}")
                    contentType(ContentType.Application.Json)
                    setBody("""{"from":"Acme <noreply@yourdomain.com>","to":"${r.email}","subject":"${r.subject}","html":"${r.html}"}""")
                }
                println("${r.email}: ${response.status.value}")
            }
        }
    }.awaitAll()

    httpClient.close()
}

Verify webhooks in Ktor server

Rovela signs every delivery event with HMAC-SHA256. The signature arrives in the x-resend-signature header as t=<timestamp>,v1=<hex-signature>. Compute HMAC-SHA256 over <timestamp>.<raw-body> using your webhook secret and compare with a constant-time function.

import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import java.security.MessageDigest

fun Route.rovelaWebhook() {
    val secret = System.getenv("ROVELA_WEBHOOK_SECRET")!!

    post("/webhooks/rovela") {
        val rawBody = call.receive<ByteArray>()
        val sigHeader = call.request.header("x-resend-signature") ?: ""

        val parts = sigHeader.split(",")
            .mapNotNull { part ->
                val kv = part.split("=", limit = 2)
                if (kv.size == 2) kv[0] to kv[1] else null
            }.toMap()

        val timestamp = parts["t"] ?: return@post call.respond(io.ktor.http.HttpStatusCode.BadRequest, "missing timestamp")
        val v1        = parts["v1"] ?: return@post call.respond(io.ktor.http.HttpStatusCode.BadRequest, "missing v1")

        val signedString = "$timestamp.${rawBody.decodeToString()}"
        val mac = Mac.getInstance("HmacSHA256")
        mac.init(SecretKeySpec(secret.toByteArray(), "HmacSHA256"))
        val expected = mac.doFinal(signedString.toByteArray())
            .joinToString("") { "%02x".format(it) }

        if (!MessageDigest.isEqual(expected.toByteArray(), v1.toByteArray())) {
            return@post call.respond(io.ktor.http.HttpStatusCode.BadRequest, "invalid signature")
        }

        // parse and handle event
        call.respond("ok")
    }
}

Call call.receive<ByteArray>() before any JSON parsing so the raw body is intact for signature verification. If you use the ContentNegotiation plugin globally, install it with ignoreType<ByteArray>() or handle the raw bytes endpoint before installing the plugin in the routing hierarchy.

Verify webhooks in Spring Boot

import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import java.security.MessageDigest

@RestController
@RequestMapping("/webhooks")
class RovelaWebhookController {

    private val secret = System.getenv("ROVELA_WEBHOOK_SECRET")!!

    @PostMapping("/rovela")
    fun handle(
        @RequestHeader("x-resend-signature") sigHeader: String,
        @RequestBody rawBody: ByteArray,
    ): ResponseEntity<String> {
        val parts = sigHeader.split(",")
            .mapNotNull { p -> p.split("=", limit = 2).takeIf { it.size == 2 }?.let { it[0] to it[1] } }
            .toMap()

        val timestamp = parts["t"] ?: return ResponseEntity.badRequest().body("missing timestamp")
        val v1        = parts["v1"] ?: return ResponseEntity.badRequest().body("missing v1")

        val signedString = "$timestamp.${rawBody.decodeToString()}"
        val mac = Mac.getInstance("HmacSHA256")
        mac.init(SecretKeySpec(secret.toByteArray(), "HmacSHA256"))
        val expected = mac.doFinal(signedString.toByteArray())
            .joinToString("") { "%02x".format(it) }

        if (!MessageDigest.isEqual(expected.toByteArray(), v1.toByteArray())) {
            return ResponseEntity.badRequest().body("invalid signature")
        }

        // parse event from rawBody with ObjectMapper
        return ResponseEntity.ok("ok")
    }
}

Add the following to your Spring Boot config so the controller receives the raw bytes before the default JSON deserialization:

# application.yml
spring:
  mvc:
    contentnegotiation:
      use-path-extension: false

And ensure your @RequestBody parameter type is ByteArray (not a data class) on the webhook endpoint — this bypasses the HttpMessageConverter chain for that method.