← Back to blog

Send Email with Ruby using the Rovela API

Learn how to send transactional email from Ruby using Net::HTTP, Faraday, and HTTParty. Includes retry logic with idempotency keys, batch sending, and webhook signature verification for Sinatra and Rails.

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

Sending transactional email from Ruby is straightforward with the Rovela API. This guide covers Net::HTTP (zero dependencies), Faraday, and HTTParty, plus webhook verification for Sinatra and Rails.

Prerequisites

You need a Rovela API key. Set it as an environment variable:

export ROVELA_API_KEY="re_..."

Send with Net::HTTP

No gems required — just Ruby's standard library.

require "net/http"
require "json"
require "uri"

def send_email(to:, subject:, html:, text: nil, idempotency_key: nil)
  uri = URI("https://api.rovela.dev/emails")
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true

  body = { from: "Acme <noreply@yourdomain.com>", to: to, subject: subject, html: html }
  body[:text] = text if text
  body[:idempotency_key] = idempotency_key if idempotency_key

  request = Net::HTTP::Post.new(uri)
  request["Authorization"] = "Bearer #{ENV.fetch("ROVELA_API_KEY")}"
  request["Content-Type"]  = "application/json"
  request.body = body.to_json

  response = http.request(request)
  [response.code.to_i, JSON.parse(response.body)]
end

status, data = send_email(
  to: "user@example.com",
  subject: "Welcome aboard",
  html: "<p>Thanks for signing up!</p>"
)
puts "#{status}: #{data}"

Send with Faraday

Faraday is a popular Ruby HTTP client with middleware support.

require "faraday"
require "json"

conn = Faraday.new("https://api.rovela.dev") do |f|
  f.request  :json
  f.response :json
  f.adapter  Faraday.default_adapter
end

response = conn.post("/emails") do |req|
  req.headers["Authorization"] = "Bearer #{ENV.fetch("ROVELA_API_KEY")}"
  req.body = {
    from:    "Acme <noreply@yourdomain.com>",
    to:      "user@example.com",
    subject: "Welcome aboard",
    html:    "<p>Thanks for signing up!</p>"
  }
end

puts response.status
puts response.body["id"]

Send with HTTParty

HTTParty adds convenient class-level helpers for REST APIs.

require "httparty"

class RovelaClient
  include HTTParty
  base_uri "https://api.rovela.dev"

  def initialize(api_key)
    @headers = {
      "Authorization" => "Bearer #{api_key}",
      "Content-Type"  => "application/json"
    }
  end

  def send_email(params)
    self.class.post("/emails", body: params.to_json, headers: @headers)
  end
end

client = RovelaClient.new(ENV.fetch("ROVELA_API_KEY"))
response = client.send_email(
  from:    "Acme <noreply@yourdomain.com>",
  to:      "user@example.com",
  subject: "Welcome aboard",
  html:    "<p>Thanks for signing up!</p>"
)
puts response.code
puts response["id"]

Retries and idempotency

Generate the idempotency key once before the loop, then pass the same key on every attempt. This prevents duplicate deliveries when the network drops after the server accepts the request.

require "net/http"
require "json"
require "securerandom"

MAX_RETRIES   = 4
BASE_DELAY    = 1.0  # seconds

def send_with_retry(payload)
  idempotency_key = SecureRandom.uuid  # generated once, reused on every retry
  uri  = URI("https://api.rovela.dev/emails")
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true

  MAX_RETRIES.times do |attempt|
    req = Net::HTTP::Post.new(uri)
    req["Authorization"]   = "Bearer #{ENV.fetch("ROVELA_API_KEY")}"
    req["Content-Type"]    = "application/json"
    req["Idempotency-Key"] = idempotency_key
    req.body = payload.to_json

    res = http.request(req)
    return [res.code.to_i, JSON.parse(res.body)] unless res.code.to_i == 429

    retry_after = res["Retry-After"]&.to_f || BASE_DELAY * (2 ** attempt)
    sleep retry_after
  end

  raise "Rate limit exceeded after #{MAX_RETRIES} attempts"
end

Send to multiple recipients

For bulk sending, cap concurrency to respect rate limits. Ruby's Thread plus a counting semaphore keeps it simple without extra dependencies.

require "net/http"
require "json"
require "securerandom"

CONCURRENCY = 5

def send_batch(recipients)
  semaphore = Mutex.new
  available = CONCURRENCY
  cond      = ConditionVariable.new
  threads   = []

  recipients.each do |r|
    semaphore.synchronize { cond.wait(semaphore) while available.zero?; available -= 1 }

    threads << Thread.new(r) do |recipient|
      begin
        uri  = URI("https://api.rovela.dev/emails")
        http = Net::HTTP.new(uri.host, uri.port)
        http.use_ssl = true
        req = Net::HTTP::Post.new(uri)
        req["Authorization"]   = "Bearer #{ENV.fetch("ROVELA_API_KEY")}"
        req["Content-Type"]    = "application/json"
        req["Idempotency-Key"] = SecureRandom.uuid
        req.body = {
          from: "Acme <noreply@yourdomain.com>",
          to: recipient[:email], subject: recipient[:subject], html: recipient[:html]
        }.to_json
        res = http.request(req)
        puts "#{recipient[:email]}: #{res.code}"
      ensure
        semaphore.synchronize { available += 1; cond.signal }
      end
    end
  end

  threads.each(&:join)
end

Verify webhooks in Sinatra

Rovela signs every event with HMAC-SHA256. The signature arrives in the x-resend-signature header as t=<timestamp>,v1=<hex-signature>. Sign the string <timestamp>.<raw-body> with your webhook secret and compare using a constant-time function.

require "sinatra"
require "openssl"
require "json"

WEBHOOK_SECRET = ENV.fetch("ROVELA_WEBHOOK_SECRET")

post "/webhooks/rovela" do
  raw_body  = request.body.read
  sig_header = request.env["HTTP_X_RESEND_SIGNATURE"] || ""

  parts     = sig_header.split(",").map { |p| p.split("=", 2) }.to_h
  timestamp = parts["t"]
  v1        = parts["v1"]

  halt 400, "Missing signature fields" if timestamp.nil? || v1.nil?

  signed_string = "#{timestamp}.#{raw_body}"
  expected      = OpenSSL::HMAC.hexdigest("SHA256", WEBHOOK_SECRET, signed_string)

  unless Rack::Utils.secure_compare(expected, v1)
    halt 400, "Invalid signature"
  end

  payload = JSON.parse(raw_body)
  case payload["type"]
  when "email.delivered"
    # mark delivered
  when "email.bounced"
    # handle bounce
  end

  status 200
  "ok"
end

Rack::Utils.secure_compare does a constant-time string comparison, preventing timing attacks.

Verify webhooks in Rails

In Rails you typically read the raw body with request.body.read. Make sure your controller skips CSRF verification for this endpoint.

# app/controllers/webhooks/rovela_controller.rb
class Webhooks::RovelaController < ApplicationController
  skip_before_action :verify_authenticity_token
  WEBHOOK_SECRET = ENV.fetch("ROVELA_WEBHOOK_SECRET")

  def create
    raw_body  = request.body.read
    sig_header = request.headers["X-Resend-Signature"] || ""

    parts     = sig_header.split(",").map { |p| p.split("=", 2) }.to_h
    timestamp = parts["t"]
    v1        = parts["v1"]

    return head :bad_request if timestamp.nil? || v1.nil?

    signed_string = "#{timestamp}.#{raw_body}"
    expected = OpenSSL::HMAC.hexdigest("SHA256", WEBHOOK_SECRET, signed_string)

    unless ActiveSupport::SecurityUtils.secure_compare(expected, v1)
      return head :bad_request
    end

    payload = JSON.parse(raw_body)
    case payload["type"]
    when "email.delivered"
      # mark delivered in your DB
    when "email.bounced"
      # flag the address
    end

    head :ok
  end
end
# config/routes.rb
post "/webhooks/rovela", to: "webhooks/rovela#create"

ActiveSupport::SecurityUtils.secure_compare is Rails' constant-time comparison helper.