← Back to blog

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.

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

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.