← Back to blog

Send Email with Haskell using the Rovela API

Learn how to send transactional email from Haskell using http-conduit or req, with retry logic and idempotency keys, concurrent batch sending via mapConcurrently and a QSem semaphore, and webhook verification using Scotty and the memory package's constEq for constant-time HMAC comparison.

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

This guide covers sending transactional email from Haskell using the Rovela API. It includes the http-conduit library, the req library as an alternative, retry logic with idempotency keys, concurrent batch sending with mapConcurrently and a semaphore, and webhook verification with Scotty using cryptonite for constant-time HMAC comparison.

Prerequisites

Set your API key as an environment variable:

export ROVELA_API_KEY="re_..."

Add dependencies to your package.yaml or .cabal file:

dependencies:
  - http-conduit >= 2.3
  - aeson >= 2.0
  - bytestring
  - text

Send with http-conduit

{-# LANGUAGE OverloadedStrings #-}

module Rovela where

import           Control.Exception       (throwIO)
import           Data.Aeson              (Value, encode, object, (.=))
import qualified Data.ByteString.Char8   as BS
import qualified Data.ByteString.Lazy    as LBS
import           Data.Text               (Text)
import           Network.HTTP.Conduit
import           Network.HTTP.Types      (statusIsSuccessful)
import           System.Environment      (getEnv)

sendEmail :: Text -> Text -> Text -> IO LBS.ByteString
sendEmail to subject html = do
  apiKey <- getEnv "ROVELA_API_KEY"
  manager <- newTlsManager

  let payload = object
        [ "from"    .= ("Acme <noreply@yourdomain.com>" :: Text)
        , "to"      .= to
        , "subject" .= subject
        , "html"    .= html
        ]

  initReq <- parseRequest "https://api.rovela.dev/emails"
  let req = initReq
        { method      = "POST"
        , requestBody = RequestBodyLBS (encode payload)
        , requestHeaders =
            [ ("Authorization",  "Bearer " <> BS.pack apiKey)
            , ("Content-Type",   "application/json")
            ]
        }

  resp <- httpLbs req manager
  if statusIsSuccessful (responseStatus resp)
    then return (responseBody resp)
    else throwIO (userError ("HTTP error: " <> show (responseStatus resp)))

Send with req

req provides a more ergonomic interface with type-safe URL building. Add req and aeson to your dependencies:

dependencies:
  - req >= 3.13
  - aeson >= 2.0
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE DataKinds         #-}

module Rovela.Req where

import           Data.Aeson              (object, (.=))
import           Data.Text               (Text)
import qualified Data.Text               as T
import qualified Data.Text.Encoding      as TE
import           Network.HTTP.Req
import           System.Environment      (getEnv)import           System.Environment      (getEnv)

sendEmail :: Text -> Text -> Text -> IO BsResponse
sendEmail to subject html = do
  apiKey <- getEnv "ROVELA_API_KEY"

  let payload = object
        [ "from"    .= ("Acme <noreply@yourdomain.com>" :: Text)
        , "to"      .= to
        , "subject" .= subject
        , "html"    .= html
        ]

  runReq defaultHttpConfig $ do
    req POST
      (https "api.rovela.dev" /: "emails")
      (ReqBodyJson payload)
      bsResponse
      ( header "Authorization" ("Bearer " <> TE.encodeUtf8 (T.pack apiKey))
     <> header "Content-Type" "application/json"
      )

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.

{-# LANGUAGE OverloadedStrings #-}

module Rovela.Retry where

import           Control.Concurrent      (threadDelay)
import           Control.Exception       (throwIO)
import           Data.Aeson              (Value, encode)
import qualified Data.ByteString.Char8   as BS
import qualified Data.ByteString.Lazy    as LBS
import           Network.HTTP.Conduit
import           Network.HTTP.Types      (Status (statusCode))
import           System.Environment      (getEnv)
import           System.Random           (randomRIO)
import           Text.Printf             (printf)

randomHex :: Int -> IO String
randomHex n = do
  bytes <- mapM (\_ -> randomRIO (0, 255 :: Int)) [1..n]
  return $ concatMap (printf "%02x") bytes

sendWithRetry :: Value -> Int -> IO LBS.ByteString
sendWithRetry payload maxRetries = do
  apiKey        <- getEnv "ROVELA_API_KEY"
  idempotencyKey <- randomHex 16  -- generated once, reused on every retry
  manager       <- newTlsManager
  initReq       <- parseRequest "https://api.rovela.dev/emails"

  let baseReq = initReq
        { method      = "POST"
        , requestBody = RequestBodyLBS (encode payload)
        , requestHeaders =
            [ ("Authorization",   "Bearer " <> BS.pack apiKey)
            , ("Content-Type",    "application/json")
            , ("Idempotency-Key", BS.pack idempotencyKey)
            ]
        }

  let go attempt = do
        resp <- httpLbs baseReq manager
        let code = statusCode (responseStatus resp)
        case code of
          429 | attempt < maxRetries -> do
            let delayUs = 2 ^ attempt * 1000000 -- exponential backoff in microseconds
            threadDelay delayUs
            go (attempt + 1)
          _ | code >= 200 && code < 300 ->
            return (responseBody resp)
          _ ->
            throwIO (userError ("HTTP error: " <> show code))

  go 0

Batch sending with mapConcurrently

mapConcurrently from async runs a list of actions in parallel. Wrap it with a QSem to cap the number of in-flight requests:

dependencies:
  - async >= 2.2
{-# LANGUAGE OverloadedStrings #-}

module Rovela.Batch where

import           Control.Concurrent        (newQSem, signalQSem, waitQSem)
import           Control.Concurrent.Async  (mapConcurrently)
import           Control.Exception         (bracket_)
import           Data.Text                 (Text)
import           Rovela                    (sendEmail)

data Recipient = Recipient
  { recipientEmail   :: Text
  , recipientSubject :: Text
  , recipientHtml    :: Text
  }

sendBatch :: [Recipient] -> Int -> IO [Either String String]
sendBatch recipients concurrency = do
  sem <- newQSem concurrency   -- semaphore caps concurrent requests

  mapConcurrently (withSem sem . sendOne) recipients
  where
    withSem sem action =
      bracket_ (waitQSem sem) (signalQSem sem) action

    sendOne r = do
      _ <- sendEmail (recipientEmail r) (recipientSubject r) (recipientHtml r)
      return (Right "sent" :: Either String String)

QSem is a counting semaphore from Control.Concurrent. waitQSem decrements it (blocking when the count reaches zero), and signalQSem increments it, ensuring at most concurrency requests are in flight at any time.

Verify webhooks with Scotty

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 constEq from memory for constant-time comparison.

Add dependencies:

dependencies:
  - scotty >= 0.20
  - cryptonite >= 0.30
  - memory >= 0.18
  - wai >= 3.2
  - bytestring
{-# LANGUAGE OverloadedStrings #-}

module Rovela.Webhook where

import qualified Crypto.Hash.Algorithms  as Hash
import qualified Crypto.MAC.HMAC         as HMAC
import           Control.Monad.IO.Class  (liftIO)
import qualified Data.ByteArray          as BA
import           Data.ByteArray          (constEq)
import qualified Data.ByteArray.Encoding as BAE
import qualified Data.ByteString         as BS
import qualified Data.ByteString.Char8   as BSC
import qualified Data.ByteString.Lazy    as LBS
import qualified Data.Text.Encoding      as TE
import qualified Data.Text.Lazy          as TL
import           Network.HTTP.Types      (status400)
import           System.Environment      (getEnv)
import           Web.Scottyimport           Web.Scotty

-- Parse "t=<ts>,v1=<hex>" into (timestamp, v1hex)
parseSigHeader :: BS.ByteString -> Maybe (BS.ByteString, BS.ByteString)
parseSigHeader hdr = do
  let parts = map (BSC.break (== '=')) (BSC.splitWith (== ',') hdr)
      toKV (k, v)
        | BS.null v = Nothing
        | otherwise = Just (k, BS.tail v)
      kvs = [kv | Just kv <- map toKV parts]
  t  <- lookup "t"  kvs
  v1 <- lookup "v1" kvs
  return (t, v1)

verifySignature :: BS.ByteString -> BS.ByteString -> BS.ByteString -> BS.ByteString -> Bool
verifySignature secret timestamp rawBody v1Hex =
  case BAE.convertFromBase BAE.Base16 v1Hex of
    Left  _        -> False
    Right provided ->
      let signingInput = timestamp <> "." <> rawBody
          expected     = HMAC.hmac secret signingInput :: HMAC.HMAC Hash.SHA256
          expectedBS   = HMAC.hmacGetDigest expected
      -- constEq performs constant-time comparison, preventing timing attacks
      in constEq (BA.convert expectedBS :: BS.ByteString) provided

main :: IO ()
main = scotty 8080 $ do
  post "/webhooks/rovela" $ do
    secret    <- liftIO (getEnv "ROVELA_WEBHOOK_SECRET")
    rawBody   <- body
    sigHeader <- header "x-resend-signature"

    let sigBS = maybe "" (TE.encodeUtf8 . TL.toStrict) sigHeader

    case parseSigHeader sigBS of
      Nothing -> do
        status status400
        text "missing signature fields"
      Just (ts, v1Hex) -> do
        if verifySignature (BSC.pack secret) ts (LBS.toStrict rawBody) v1Hex
          then do
            -- parse payload and handle event type
            text "ok"
          else do
            status status400
            text "invalid signature"

constEq from the memory package performs a byte-by-byte comparison without short-circuiting, ensuring the comparison time does not leak information about which bytes matched.