← Back to blog

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.

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

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.