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

How to send email with Ruby using an email API or SMTP

Send email from Ruby apps using the Sendkit SDK. Covers gem setup, HTML email, MIME sending, error handling, and SMTP relay.

rubyemail-apitransactional-emailtutorial
How to send email with Ruby using an email API or SMTP

Ruby's Net::SMTP has been around forever, and it shows. You end up manually building MIME parts, fiddling with Content-Type headers, and writing more email plumbing than actual application logic. ActionMailer abstracts some of this away in Rails, but if you're outside Rails or want direct control over delivery tracking, you're back to square one.

An email API is simpler. You send a hash of parameters, get back a response, and move on. This guide walks through sending email from Ruby using the Sendkit email API, starting with basic sends and working up to MIME messages, error handling, and SMTP as a fallback.

Code on a screen

Install the gem

Add it to your Gemfile:

gem "sendkit"

Then run bundle install. Or install it directly:

gem install sendkit

You'll need an API key from your Sendkit dashboard. Keys prefixed with sk_live_ send real email. Keys prefixed with sk_test_ validate your request but don't deliver anything, which is useful for test suites.

Initialize the client

Two options. Pass the key directly:

require "sendkit"

client = Sendkit::Client.new("sk_live_your_api_key")

Or set the SENDKIT_API_KEY environment variable and let the client pick it up:

client = Sendkit::Client.new

I prefer the env var approach. It keeps keys out of version control and makes it easy to swap between test and live keys per environment.

Code setup

Send a plain text email

The most basic send:

result = client.emails.send(
  from: "[email protected]",
  to: "[email protected]",
  subject: "Your order has shipped",
  text: "Order #4821 shipped via FedEx. Tracking number: 7489201384."
)

puts result["id"]

result["id"] is the message ID. Hold onto it if you need to look up delivery status later or correlate with webhook events.

The from address must be on a domain you've verified in Sendkit. If you try to send from an unverified domain, you'll get a validation error back.

Send an HTML email

Most transactional email needs HTML. Pass an html parameter alongside (or instead of) text:

result = client.emails.send(
  from: "[email protected]",
  to: "[email protected]",
  subject: "Invoice #1042",
  html: <<~HTML
    <h1>Invoice #1042</h1>
    <p>Amount due: <strong>$249.00</strong></p>
    <p>Due date: March 15, 2026</p>
    <p><a href="https://yourapp.com/invoices/1042">View invoice</a></p>
  HTML
)

If you include both text and html, email clients that can't render HTML will fall back to the plain text version. Worth doing for accessibility, even though it's rare in practice.

Send to multiple recipients

Pass an array for to:

result = client.emails.send(
  from: "[email protected]",
  to: ["[email protected]", "[email protected]"],
  subject: "Weekly report",
  html: "<p>This week's metrics are attached.</p>"
)

Each recipient gets their own copy. This isn't BCC. Each address appears in the To header of the message they receive.

Reply-to and custom headers

Set a reply_to when you want responses going somewhere other than the from address:

result = client.emails.send(
  from: "[email protected]",
  to: "[email protected]",
  reply_to: "[email protected]",
  subject: "Your account has been created",
  html: "<p>Welcome aboard. Reply to this email if you have any questions.</p>"
)

This is common for transactional email where the sending address is a noreply@ but you still want replies to land in a real inbox.

Error handling

The SDK raises Sendkit::Error when something goes wrong. The error object has three useful attributes: name, message, and status_code.

begin
  result = client.emails.send(
    from: "[email protected]",
    to: "[email protected]",
    subject: "Test",
    html: "<p>Hello</p>"
  )
  puts result["id"]
rescue Sendkit::Error => e
  puts e.name        # "validation_error"
  puts e.message     # "The from domain is not verified."
  puts e.status_code # 422
end

Error handling

Common errors you'll hit:

  • 422 with validation_error: Missing or invalid fields. Usually a bad from domain or missing to.
  • 401 with authentication_error: Invalid API key. Double-check you're using the right key for the environment.
  • 429 with rate_limit_error: Too many requests. The response includes a Retry-After header telling you how long to wait. Sendkit allows 60 to 1,200 requests per minute depending on your plan.

For production code, catch rate limits specifically and retry with backoff:

def send_with_retry(client, params, max_retries: 3)
  retries = 0
  begin
    client.emails.send(**params)
  rescue Sendkit::Error => e
    if e.status_code == 429 && retries < max_retries
      retries += 1
      sleep(retries * 2)
      retry
    end
    raise
  end
end

Send raw MIME messages

If you already have a MIME-formatted message (from Mail gem, or because you're migrating from another provider), use send_mime:

require "mail"

mime = Mail.new do
  from    "[email protected]"
  to      "[email protected]"
  subject "Monthly statement"

  text_part do
    body "Your statement is attached."
  end

  html_part do
    content_type "text/html; charset=UTF-8"
    body "<p>Your statement is attached.</p>"
  end

  add_file "/path/to/statement.pdf"
end

client.emails.send_mime(
  envelope_from: "[email protected]",
  envelope_to: "[email protected]",
  raw_message: mime.to_s
)

This is the escape hatch for anything the standard send method doesn't cover: inline images, S/MIME signing, calendar invitations, or multipart messages with unusual structures.

Using SMTP instead

If you'd rather use SMTP, Sendkit works as a drop-in SMTP relay. This is handy if you're already using ActionMailer or another library that speaks SMTP.

Configure your SMTP settings:

require "net/smtp"

smtp = Net::SMTP.new("smtp.sendkit.dev", 587)
smtp.enable_starttls

smtp.start("yourdomain.com", "sendkit", "sk_live_your_api_key", :plain) do |s|
  s.send_message(
    "From: [email protected]\r\n" \
    "To: [email protected]\r\n" \
    "Subject: Test\r\n" \
    "\r\n" \
    "Hello from SMTP.",
    "[email protected]",
    "[email protected]"
  )
end

Your API key is the SMTP password. No separate credentials to manage.

For Rails apps, update config/environments/production.rb:

config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
  address: "smtp.sendkit.dev",
  port: 587,
  user_name: "sendkit",
  password: ENV["SENDKIT_API_KEY"],
  authentication: :plain,
  enable_starttls_auto: true,
  domain: "yourapp.com"
}

SMTP gives you the same tracking (opens, clicks, bounces) as the API. The difference is the API returns a message ID synchronously, while SMTP is fire-and-forget at the protocol level. If you need confirmation of acceptance, the API is more predictable.

Sending from a background job

Email sending shouldn't block your web requests. In a Rails app, push it to a Sidekiq job:

class SendEmailJob
  include Sidekiq::Job

  def perform(to, subject, html)
    client = Sendkit::Client.new
    client.emails.send(
      from: "[email protected]",
      to: to,
      subject: subject,
      html: html
    )
  rescue Sendkit::Error => e
    if e.status_code == 429
      # Re-enqueue with delay on rate limit
      self.class.perform_in(30, to, subject, html)
    else
      raise
    end
  end
end

# Enqueue from your controller
SendEmailJob.perform_async("[email protected]", "Welcome!", "<p>Hello</p>")

The Sendkit::Client.new call in the job reads from SENDKIT_API_KEY, so no secrets in your job arguments.

Putting it together

Here's a more realistic example. An order confirmation with error handling and logging:

require "sendkit"
require "logger"

logger = Logger.new($stdout)
client = Sendkit::Client.new

def send_order_confirmation(client, logger, order)
  result = client.emails.send(
    from: "[email protected]",
    to: order[:email],
    reply_to: "[email protected]",
    subject: "Order ##{order[:id]} confirmed",
    html: <<~HTML
      <h1>Thanks for your order</h1>
      <p>Order ##{order[:id]}</p>
      <p>Total: $#{order[:total]}</p>
      <p>We'll email you again when it ships.</p>
    HTML
  )

  logger.info("Sent order confirmation #{result['id']} to #{order[:email]}")
  result
rescue Sendkit::Error => e
  logger.error("Failed to send order confirmation: #{e.name} - #{e.message}")
  raise
end

send_order_confirmation(client, logger, {
  id: 8832,
  email: "[email protected]",
  total: "149.00"
})

What to read next

The Ruby SDK covers the same surface as the REST API. If you can do it with a curl request, you can do it with the gem. The main advantage is typed errors and not having to manage HTTP headers yourself.

Share this article