← Back to blog
·6 min read·
Paulo CastellanoPaulo Castellano

How to send email with Ruby on Rails using Sendkit

Set up Sendkit in your Rails app with the Ruby SDK or as an Action Mailer transport. Covers mailers, async jobs, and error handling.

railsrubyemail-apitutorial
How to send email with Ruby on Rails using Sendkit

Most Rails apps outgrow letter_opener and localhost SMTP pretty fast. You need transactional email that actually lands in inboxes, tracking that tells you what happened after delivery, and an integration that doesn't fight Rails conventions. ActionMailer is great for defining mailers, but the delivery backend matters more than most teams realize.

This guide sets up Sendkit in a Rails app two ways: directly through the Ruby SDK for full control, and as an Action Mailer delivery method for drop-in compatibility with your existing mailers.

Laptop with code on screen

Install the gem

Add Sendkit to your Gemfile:

gem "sendkit"

Run bundle install. Grab an API key from your Sendkit dashboard. Keys prefixed with sk_live_ send real email. Keys prefixed with sk_test_ validate requests without delivering, which is what you want in your test suite.

Store the key in your credentials or environment:

# .env (if using dotenv)
SENDKIT_API_KEY=sk_live_your_api_key

Configure with an initializer

Create config/initializers/sendkit.rb:

require "sendkit"

SENDKIT_CLIENT = Sendkit::Client.new(ENV.fetch("SENDKIT_API_KEY"))

One client instance, initialized once at boot, reused everywhere. The client is thread-safe, so this works fine with Puma's threaded workers.

Send a basic email from a controller

The simplest path. Skip ActionMailer entirely and call the SDK directly:

class OrdersController < ApplicationController
  def create
    @order = Order.create!(order_params)

    SENDKIT_CLIENT.emails.send(
      from: "[email protected]",
      to: @order.user.email,
      subject: "Order ##{@order.id} confirmed",
      html: render_to_string(
        partial: "orders/confirmation_email",
        locals: { order: @order }
      )
    )

    redirect_to @order, notice: "Order placed."
  end
end

render_to_string lets you use your existing Rails partials as email templates. No separate mailer class needed. This approach works when you have a handful of emails and don't need the full ActionMailer ceremony.

The response hash includes an id field. Store it if you want to correlate with webhook events later.

Coding on a dark screen

Use Action Mailer with Sendkit as delivery method

For most Rails apps, Action Mailer is the right abstraction. You get view templates, layouts, previews, and interceptors. The missing piece is a delivery method that sends through Sendkit's API instead of SMTP.

Create a custom delivery method:

# lib/sendkit_delivery_method.rb
class SendkitDeliveryMethod
  attr_reader :settings

  def initialize(settings)
    @settings = settings
    @client = Sendkit::Client.new(settings[:api_key])
  end

  def deliver!(mail)
    params = {
      from: mail.from&.first,
      to: Array(mail.to),
      subject: mail.subject
    }

    params[:html] = mail.html_part&.body&.to_s if mail.html_part
    params[:text] = mail.text_part&.body&.to_s if mail.text_part
    params[:html] ||= mail.body.to_s if mail.content_type&.include?("text/html")
    params[:text] ||= mail.body.to_s unless params[:html]
    params[:reply_to] = mail.reply_to.first if mail.reply_to&.any?

    @client.emails.send(**params)
  end
end

Register it in your environment config:

# config/environments/production.rb
require_relative "../../lib/sendkit_delivery_method"

ActionMailer::Base.add_delivery_method :sendkit, SendkitDeliveryMethod,
  api_key: ENV.fetch("SENDKIT_API_KEY")

config.action_mailer.delivery_method = :sendkit

Now every mailer in your app sends through Sendkit's email API without changing a single mailer class:

class UserMailer < ApplicationMailer
  def welcome(user)
    @user = user
    mail(
      to: @user.email,
      subject: "Welcome to the platform"
    )
  end
end

# Call it as usual
UserMailer.welcome(@user).deliver_now

Your existing mailer tests, previews, and interceptors all keep working. The only thing that changed is the wire protocol underneath.

Background jobs with Active Job and Sidekiq

Never send email inside a web request if you can avoid it. Network calls to any external API add latency and introduce failure modes your users shouldn't see.

Rails makes this trivial with deliver_later:

UserMailer.welcome(@user).deliver_later

That's it. Active Job picks it up, and your configured queue adapter (Sidekiq, Solid Queue, Good Job) handles the rest. Rails automatically retries failed jobs, so transient Sendkit errors get handled for free.

If you're using the SDK directly instead of Action Mailer, wrap it in an Active Job:

class SendEmailJob < ApplicationJob
  queue_as :mailers
  retry_on Sendkit::Error, wait: :polynomially_longer, attempts: 5

  def perform(to:, subject:, html:)
    SENDKIT_CLIENT.emails.send(
      from: "[email protected]",
      to: to,
      subject: subject,
      html: html
    )
  end
end

# Enqueue from anywhere
SendEmailJob.perform_later(
  to: "[email protected]",
  subject: "Your report is ready",
  html: "<p>Download it from your dashboard.</p>"
)

retry_on Sendkit::Error catches both rate limits and transient failures. The polynomially_longer wait strategy spaces retries out so you don't hammer the API after a 429.

For Sidekiq specifically, you can also use native Sidekiq jobs with more granular retry control:

class SendEmailWorker
  include Sidekiq::Job
  sidekiq_options retry: 5, queue: "mailers"

  def perform(to, subject, html)
    SENDKIT_CLIENT.emails.send(
      from: "[email protected]",
      to: to,
      subject: subject,
      html: html
    )
  rescue Sendkit::Error => e
    raise if e.status_code != 429
    self.class.perform_in(30, to, subject, html)
  end
end

Error handling with Sendkit::Error

The SDK raises Sendkit::Error on any non-2xx response. The error carries name, message, and status_code attributes.

begin
  SENDKIT_CLIENT.emails.send(
    from: "[email protected]",
    to: params[:email],
    subject: "Password reset",
    html: render_to_string(partial: "password_reset")
  )
rescue Sendkit::Error => e
  Rails.logger.error("[Sendkit] #{e.status_code} #{e.name}: #{e.message}")

  case e.status_code
  when 422
    # Bad input: invalid from domain, missing fields
    Sentry.capture_exception(e)
  when 401
    # API key is wrong or expired
    Sentry.capture_exception(e, level: :fatal)
  when 429
    # Rate limited, retry later
    SendEmailJob.set(wait: 1.minute).perform_later(to: params[:email], ...)
  end
end

Common status codes: 422 for validation errors, 401 for bad keys, 429 for rate limits. Check Sendkit docs for the full list. Setting up proper email deliverability practices will help you avoid most 422 errors related to domain verification.

Server room with blue lights

Webhook endpoint

Sendkit fires webhooks for deliveries, bounces, complaints, opens, and clicks. You need a Rails controller to receive them:

class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token, only: :sendkit

  def sendkit
    payload = JSON.parse(request.body.read)
    event_type = payload["type"]

    case event_type
    when "email.delivered"
      Rails.logger.info("Delivered: #{payload['data']['email_id']}")
    when "email.bounced"
      email = payload["data"]["to"]
      User.where(email: email).update_all(email_bounced: true)
      Rails.logger.warn("Bounce: #{email}")
    when "email.complained"
      email = payload["data"]["to"]
      User.where(email: email).update_all(email_opted_out: true)
    end

    head :ok
  end
end

Add the route:

# config/routes.rb
post "/webhooks/sendkit", to: "webhooks#sendkit"

In production, verify the webhook signature to make sure the request actually came from Sendkit. The docs cover signature verification. Process webhooks in a background job if the handler does anything slow -- the endpoint should return 200 quickly so Sendkit doesn't retry.

SMTP alternative

If you'd rather not write a custom delivery method or you're migrating from another provider, Sendkit works as a standard SMTP relay. Configure Action Mailer with SMTP settings:

# config/environments/production.rb
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
  address: "smtp.sendkit.com",
  port: 587,
  user_name: "sendkit",
  password: ENV.fetch("SENDKIT_API_KEY"),
  authentication: :plain,
  enable_starttls_auto: true,
  domain: "yourapp.com"
}

Your API key is the SMTP password. No separate credentials. You get the same tracking (opens, clicks, bounces) as the API. The tradeoff: SMTP is fire-and-forget at the protocol level, so you don't get a message ID back synchronously. If you need confirmation of acceptance per message, use the API.

SMTP is the fastest migration path. Swap the settings, deploy, and every existing mailer just works.

What to read next

The API approach gives you message IDs, structured errors, and tighter control. SMTP gives you zero-code migration. Pick whichever fits your situation, or use both -- Sendkit doesn't care which door you walk through.

Share this article