Send Email with Scala using the Rovela API
Learn how to send transactional email from Scala using HttpURLConnection or sttp, with retry logic and idempotency keys, concurrent batch sending via parTraverse with a cats-effect Semaphore, and webhook verification using the http4s framework with constant-time HMAC comparison.

This guide covers sending transactional email from Scala using the Rovela API. It includes the standard library HttpURLConnection, the sttp client, retry logic with idempotency keys, concurrent batch sending with parTraverse and a cats-effect Semaphore, and webhook verification with the http4s server.
Prerequisites
Set your API key as an environment variable:
export ROVELA_API_KEY="re_..."
Send with HttpURLConnection
No external dependencies — just the JDK:
import java.net.{HttpURLConnection, URL}
import scala.io.Source
import scala.util.{Try, Using}
object Rovela {
private val apiUrl = "https://api.rovela.dev/emails"
def sendEmail(to: String, subject: String, html: String): Try[String] = Try {
val apiKey = sys.env("ROVELA_API_KEY")
val conn = new URL(apiUrl).openConnection().asInstanceOf[HttpURLConnection]
conn.setRequestMethod("POST")
conn.setRequestProperty("Authorization", s"Bearer $apiKey")
conn.setRequestProperty("Content-Type", "application/json")
conn.setDoOutput(true)
val body =
s"""{"from":"Acme <noreply@yourdomain.com>","to":"$to","subject":"$subject","html":"$html"}"""
Using.resource(conn.getOutputStream) { os =>
os.write(body.getBytes("UTF-8"))
}
val code = conn.getResponseCode
val stream = if (code >= 200 && code < 300) conn.getInputStream else conn.getErrorStream
Using.resource(Source.fromInputStream(stream))(_.mkString)
}
}
Send with sttp
sttp is a common Scala HTTP client that works with several backends. Add it to build.sbt:
libraryDependencies ++= Seq(
"com.softwaremill.sttp.client3" %% "core" % "3.9.7",
"com.softwaremill.sttp.client3" %% "cats" % "3.9.7",
"io.circe" %% "circe-generic" % "0.14.9",
"org.typelevel" %% "cats-effect" % "3.5.4"
)
import sttp.client3._
import sttp.client3.httpclient.cats.HttpClientCatsBackend
import cats.effect.IO
import io.circe.generic.semiauto._
import io.circe.syntax._
import io.circe._
case class SendEmailRequest(from: String, to: String, subject: String, html: String)
object RovelaSttp {
implicit val encoder: Encoder[SendEmailRequest] = deriveEncoder
def sendEmail(req: SendEmailRequest): IO[Response[String]] = {
val apiKey = sys.env("ROVELA_API_KEY")
// resource() owns a real HttpClient-backed sttp backend
HttpClientCatsBackend.resource[IO]().use { backend =>
basicRequest
.post(uri"https://api.rovela.dev/emails")
.header("Authorization", s"Bearer $apiKey")
.header("Content-Type", "application/json")
.body(req.asJson.noSpaces)
.send(backend)
}
}
}
Retry with idempotency key
Generate the idempotency key once before the retry loop and reuse it on every attempt. Only retry on HTTP 429 — other errors propagate immediately.
import java.net.{HttpURLConnection, URL}
import java.security.SecureRandom
import scala.annotation.tailrec
import scala.io.Source
import scala.util.{Try, Using}
object RovelaRetry {
private val apiUrl = "https://api.rovela.dev/emails"
private val maxRetries = 4
private def randomKey(): String = {
val bytes = new Array[Byte](16)
new SecureRandom().nextBytes(bytes)
bytes.map(b => f"$b%02x").mkString
}
/** Returns Right(body) on 2xx, Left(status) otherwise. */
private def postOnce(apiKey: String, key: String, payload: String): Either[Int, String] = {
val conn = new URL(apiUrl).openConnection().asInstanceOf[HttpURLConnection]
conn.setRequestMethod("POST")
conn.setRequestProperty("Authorization", s"Bearer $apiKey")
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("Idempotency-Key", key)
conn.setDoOutput(true)
Using.resource(conn.getOutputStream)(_.write(payload.getBytes("UTF-8")))
val code = conn.getResponseCode
if (code >= 200 && code < 300)
Right(Using.resource(Source.fromInputStream(conn.getInputStream))(_.mkString))
else Left(code)
}
@tailrec
private def loop(attempt: Int, apiKey: String, key: String, payload: String): Try[String] =
postOnce(apiKey, key, payload) match {
case Right(body) => Try(body)
case Left(429) if attempt < maxRetries =>
Thread.sleep((math.pow(2, attempt) * 1000).toInt)
loop(attempt + 1, apiKey, key, payload)
case Left(code) =>
Try(throw new RuntimeException(s"HTTP $code"))
}
def sendWithRetry(payload: String): Try[String] = Try {
val apiKey = sys.env("ROVELA_API_KEY")
val key = randomKey() // generated once, reused on every retry
loop(0, apiKey, key, payload).get
}
}
Batch sending with Futures
parTraverse runs the sends concurrently, and a Semaphore from cats-effect caps how many are in flight:
import cats.effect.{IO, Semaphore}
import cats.syntax.all._
case class Recipient(email: String, subject: String, html: String)
def sendOne(r: Recipient): String = {
import java.net.{HttpURLConnection, URL}
import scala.io.Source
import scala.util.Using
val conn = new URL("https://api.rovela.dev/emails")
.openConnection().asInstanceOf[HttpURLConnection]
conn.setRequestMethod("POST")
conn.setRequestProperty("Authorization", s"Bearer ${sys.env("ROVELA_API_KEY")}")
conn.setRequestProperty("Content-Type", "application/json")
conn.setDoOutput(true)
val body =
s"""{"from":"Acme <noreply@yourdomain.com>","to":"${r.email}","subject":"${r.subject}","html":"${r.html}"}"""
Using.resource(conn.getOutputStream)(_.write(body.getBytes("UTF-8")))
val code = conn.getResponseCode
val stream = if (code >= 200 && code < 300) conn.getInputStream else conn.getErrorStream
Using.resource(Source.fromInputStream(stream))(_.mkString)
}
def sendBatch(recipients: List[Recipient], concurrency: Int = 5): IO[List[String]] =
Semaphore[IO](concurrency).flatMap { sem =>
recipients.parTraverse { r =>
sem.permit.use(_ => IO.blocking(sendOne(r)))
}
}
For pure-Future concurrency control without cats-effect, wrap each call in a Future and chunk the list into groups of concurrency, awaiting each group before starting the next.
Verify webhooks with http4s
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.
Add dependencies:
libraryDependencies ++= Seq(
"org.http4s" %% "http4s-dsl" % "0.23.26",
"org.http4s" %% "http4s-blaze-server" % "0.23.16"
)
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import cats.effect.{IO, IOApp}
import org.http4s._
import org.http4s.blaze.server.BlazeServerBuilder
import org.http4s.dsl.io._
import org.http4s.implicits._
import org.http4s.server.Router
import org.typelevel.ci._
import scala.util.Try
object RovelaWebhook {
private def constantTimeEqual(a: Array[Byte], b: Array[Byte]): Boolean = {
if (a.length != b.length) return false
var result = 0
var i = 0
while (i < a.length) {
result |= a(i) ^ b(i)
i += 1
}
result == 0
}
private def parseSignature(header: String): Option[(String, String)] = {
val parts = header.split(",").flatMap { part =>
part.split("=", 2) match {
case Array(k, v) => Some(k -> v)
case _ => None
}
}.toMap
for {
t <- parts.get("t")
v1 <- parts.get("v1")
} yield (t, v1)
}
private def verify(secret: String, header: String, rawBody: String): Boolean =
parseSignature(header).exists { case (ts, v1Hex) =>
Try {
val mac = Mac.getInstance("HmacSHA256")
mac.init(new SecretKeySpec(secret.getBytes("UTF-8"), "HmacSHA256"))
val expected = mac.doFinal(s"$ts.$rawBody".getBytes("UTF-8"))
// grouped(2) also yields a short tail on odd-length hex, which parseInt rejects
val provided = v1Hex.grouped(2).map(Integer.parseInt(_, 16).toByte).toArray
constantTimeEqual(expected, provided)
}.getOrElse(false)
}
def routes: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ POST -> Root / "webhooks" / "rovela" =>
val secret = sys.env("ROVELA_WEBHOOK_SECRET")
val sigHead = req.headers.get(ci"x-resend-signature").map(_.head.value)
req.as[String].flatMap { rawBody =>
sigHead match {
case None => BadRequest("missing signature")
case Some(h) if !verify(secret, h, rawBody) => BadRequest("invalid signature")
case Some(_) =>
// parse and handle the event from rawBody
Ok("ok")
}
}
}
}
object Main extends IOApp.Simple {
def run: IO[Unit] =
BlazeServerBuilder[IO]
.bindHttp(8080, "0.0.0.0")
.withHttpApp(Router("/" -> RovelaWebhook.routes).orNotFound)
.serve
.compile
.drain
}
The constantTimeEqual function XORs every byte and ORs the results, returning true only when all bytes match — preventing timing attacks.