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.

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.