Send Email with Elixir using the Rovela API
Learn how to send transactional email from Elixir using HTTPoison or Finch, with retry logic and idempotency keys, concurrent batch sending via Task.async_stream, and webhook verification for Plug and Phoenix using constant-time HMAC comparison.

This guide covers sending transactional email from Elixir using the Rovela API. It includes HTTPoison, Finch, retry with idempotency keys, concurrent batch sending with Task.async_stream, and webhook verification for Plug and Phoenix.
Prerequisites
Set your API key as an environment variable:
export ROVELA_API_KEY="re_..."
Add your HTTP client to mix.exs. The examples below use HTTPoison; the Finch section shows the alternative.
defp deps do
[
{:httpoison, "~> 2.0"},
{:jason, "~> 1.4"}
]
end
Send with HTTPoison
defmodule Rovela do
@api_url "https://api.rovela.dev/emails"
def send_email(to, subject, html) do
api_key = System.fetch_env!("ROVELA_API_KEY")
headers = [
{"Authorization", "Bearer #{api_key}"},
{"Content-Type", "application/json"}
]
body = Jason.encode!(%{
from: "Acme <noreply@yourdomain.com>",
to: to,
subject: subject,
html: html
})
case HTTPoison.post(@api_url, body, headers) do
{:ok, %HTTPoison.Response{status_code: status, body: resp_body}}
when status in 200..299 ->
{:ok, Jason.decode!(resp_body)}
{:ok, %HTTPoison.Response{status_code: status, body: resp_body}} ->
{:error, {status, resp_body}}
{:error, %HTTPoison.Error{reason: reason}} ->
{:error, reason}
end
end
end
Send with Finch
Finch is a connection-pool-based HTTP client that integrates cleanly with Elixir's supervision tree. Start it in your application supervisor:
# application.ex
children = [
{Finch, name: RovelaFinch}
]
defmodule Rovela.Finch do
@api_url "https://api.rovela.dev/emails"
def send_email(to, subject, html) do
api_key = System.fetch_env!("ROVELA_API_KEY")
headers = [
{"authorization", "Bearer #{api_key}"},
{"content-type", "application/json"}
]
body = Jason.encode!(%{
from: "Acme <noreply@yourdomain.com>",
to: to, subject: subject, html: html
})
:post
|> Finch.build(@api_url, headers, body)
|> Finch.request(RovelaFinch)
|> case do
{:ok, %Finch.Response{status: s, body: b}} when s in 200..299 ->
{:ok, Jason.decode!(b)}
{:ok, %Finch.Response{status: s, body: b}} ->
{:error, {s, b}}
{:error, reason} ->
{:error, reason}
end
end
end
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.
defmodule Rovela.Retry do
@api_url "https://api.rovela.dev/emails"
@max_retries 4
def send_with_retry(payload, attempt \\ 0, idempotency_key \\ nil) do
key = idempotency_key || :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower)
api_key = System.fetch_env!("ROVELA_API_KEY")
headers = [
{"Authorization", "Bearer #{api_key}"},
{"Content-Type", "application/json"},
{"Idempotency-Key", key}
]
case HTTPoison.post(@api_url, Jason.encode!(payload), headers) do
{:ok, %HTTPoison.Response{status_code: 429, headers: resp_headers}} when attempt < @max_retries ->
delay =
case List.keyfind(resp_headers, "retry-after", 0) do
{_, v} ->
# Retry-After may be an HTTP-date; fall back to exponential backoff
case Integer.parse(v) do
{secs, _} -> secs * 1000
:error -> trunc(:math.pow(2, attempt) * 1000)
end
nil ->
trunc(:math.pow(2, attempt) * 1000)
end
Process.sleep(delay)
send_with_retry(payload, attempt + 1, key)
{:ok, %HTTPoison.Response{status_code: s, body: b}} when s in 200..299 ->
{:ok, Jason.decode!(b)}
{:ok, %HTTPoison.Response{status_code: s, body: b}} ->
{:error, {s, b}}
{:error, %HTTPoison.Error{reason: r}} ->
{:error, r}
end
end
end
Batch sending with Task.async_stream
Task.async_stream/3 runs a function concurrently over a list and lets you cap parallelism with max_concurrency:
defmodule Rovela.Batch do
def send_batch(recipients, concurrency \\ 5) do
recipients
|> Task.async_stream(
fn %{email: email, subject: subject, html: html} ->
case Rovela.send_email(email, subject, html) do
{:ok, result} -> {:ok, email, result}
{:error, err} -> {:error, email, err}
end
end,
max_concurrency: concurrency,
timeout: 30_000,
on_timeout: :kill_task
)
|> Enum.map(fn
{:ok, result} -> result
{:exit, reason} -> {:error, :timeout, reason}
end)
end
end
Each element of recipients is a map with :email, :subject, and :html keys. The stream preserves order and collects errors without stopping the remaining tasks.
Verify webhooks in Plug
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 :crypto.hash_equals/2 for constant-time comparison.
defmodule MyApp.RovelaWebhookPlug do
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
secret = System.fetch_env!("ROVELA_WEBHOOK_SECRET")
{:ok, raw_body, conn} = Plug.Conn.read_body(conn)
sig_header = get_req_header(conn, "x-resend-signature") |> List.first("")
with {:ok, timestamp, v1_hex} <- parse_signature(sig_header),
{:ok, v1_bytes} <- decode_hex(v1_hex),
:ok <- verify(secret, timestamp, raw_body, v1_bytes) do
payload = Jason.decode!(raw_body)
conn
|> handle_event(payload)
|> send_resp(200, "ok")
else
{:error, reason} ->
conn
|> send_resp(400, inspect(reason))
|> halt()
end
end
defp parse_signature(header) do
parts =
header
|> String.split(",")
|> Enum.flat_map(fn part ->
case String.split(part, "=", parts: 2) do
[k, v] -> [{k, v}]
_ -> []
end
end)
|> Map.new()
case {Map.get(parts, "t"), Map.get(parts, "v1")} do
{nil, _} -> {:error, :missing_timestamp}
{_, nil} -> {:error, :missing_v1}
{t, v1} -> {:ok, t, v1}
end
end
defp decode_hex(hex) do
case Base.decode16(hex, case: :lower) do
{:ok, bytes} -> {:ok, bytes}
:error -> {:error, :invalid_hex}
end
end
defp verify(secret, timestamp, raw_body, v1_bytes) do
signing_input = "#{timestamp}.#{raw_body}"
expected = :crypto.mac(:hmac, :sha256, secret, signing_input)
if :crypto.hash_equals(expected, v1_bytes), do: :ok, else: {:error, :invalid_signature}
end
defp handle_event(conn, %{"type" => "email.delivered"}), do: conn
defp handle_event(conn, %{"type" => "email.bounced"}), do: conn
defp handle_event(conn, _payload), do: conn
end
Mount the plug in your router:
# router.ex (Plug.Router)
plug "/webhooks/rovela", MyApp.RovelaWebhookPlug
Verify webhooks in Phoenix
In Phoenix, read the raw body with a custom body reader before the JSON parser can consume it, then verify the signature in a controller:
# endpoint.ex — replace the default Plug.Parsers with a raw-body caching reader
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
body_reader: {MyApp.CacheBodyReader, :read_body, []},
json_decoder: Jason
# lib/my_app/cache_body_reader.ex
defmodule MyApp.CacheBodyReader do
def read_body(conn, opts) do
{:ok, body, conn} = Plug.Conn.read_body(conn, opts)
conn = Plug.Conn.put_private(conn, :raw_body, body)
{:ok, body, conn}
end
end
# webhook_controller.ex
defmodule MyAppWeb.WebhookController do
use MyAppWeb, :controller
def rovela(conn, _params) do
secret = System.fetch_env!("ROVELA_WEBHOOK_SECRET")
raw_body = conn.private[:raw_body] || ""
sig_header = get_req_header(conn, "x-resend-signature") |> List.first("")
with {:ok, ts, v1_hex} <- parse_sig(sig_header),
{:ok, v1} <- Base.decode16(v1_hex, case: :lower),
expected = :crypto.mac(:hmac, :sha256, secret, "#{ts}.#{raw_body}"),
true <- :crypto.hash_equals(expected, v1) do
handle_event(conn.body_params["type"])
send_resp(conn, 200, "ok")
else
_ -> send_resp(conn, 400, "invalid signature")
end
end
defp parse_sig(header) do
parts = for kv <- String.split(header, ","),
[k, v] <- [String.split(kv, "=", parts: 2)],
into: %{}, do: {k, v}
case {parts["t"], parts["v1"]} do
{t, v} when not is_nil(t) and not is_nil(v) -> {:ok, t, v}
_ -> {:error, :malformed}
end
end
defp handle_event("email.delivered"), do: :ok
defp handle_event("email.bounced"), do: :ok
defp handle_event(_), do: :ok
end
Add the route in router.ex:
scope "/webhooks" do
post "/rovela", WebhookController, :rovela
end
:crypto.hash_equals/2 performs a constant-time comparison, preventing timing attacks.