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.

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.