# Send Email with Ruby on Rails Using an API

> Send email with Ruby on Rails through an email API: a custom ActionMailer delivery method, so mailers and deliver_later use Cresca, with idempotent retries.

Canonical: https://www.cresca.xyz/blog/send-email-rails
Author: Lalit Kumar Jangid
Published: 2026-10-27
Last updated: 2026-10-27

Rails gives you ActionMailer for building emails and lets you choose how they are delivered. To **send email with Ruby on Rails** through an email API instead of SMTP, add a small delivery method: your mailers, views, previews and `deliver_later` calls stay exactly as they are, and every email goes out through Cresca from your own verified domain. This tutorial adds the delivery method, shows idempotent sends from mailers, and covers templates, attachments and batches.

**TL;DR**

- Register a `:cresca` delivery method with `ActionMailer::Base.add_delivery_method`.
- Set `config.action_mailer.delivery_method = :cresca` in production.
- Existing mailers and `deliver_later` keep working, attachments included.
- Pass an idempotency key from the mailer so an Active Job retry never sends twice.

## Before you start

1. **Verify a sending domain.** In Cresca, open [Sending Domains](https://app.cresca.xyz/sending-domains), add your domain and publish the DNS records it shows (DKIM, a custom MAIL FROM domain and an ownership record). If your DNS is on Cloudflare, Cresca can publish them for you.
2. **Add a sender** on that domain, for example `hello@acme.com`. Every `from` address you send from must be an active sender on a verified domain.
3. **Create an API key** on the [API keys](https://app.cresca.xyz/api-keys) page. Choose *Sending access* for production: it can send email but cannot read logs or change templates. The key starts with `cr_live_` and is shown once, so store it as the environment variable `CRESCA_API_KEY`.

The examples need Rails 7 or later. The client uses only Ruby's standard library. The full setup, with screenshots of each step, is in the [email API quickstart](https://www.cresca.xyz/docs/quickstart).

## Step 1: the API client

Save the client from the [Ruby tutorial](https://www.cresca.xyz/blog/send-email-ruby) as `lib/cresca.rb`. It retries network errors, `429` and `5xx` responses, and raises `Cresca::Error` with the API's error name:

```ruby
# lib/cresca.rb: a small client for the Cresca email API.
require "net/http"
require "json"

module Cresca
  API_URL = "https://api.cresca.xyz/api/v1"
  ATTEMPTS = 4
  NETWORK_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, SocketError, SystemCallError, IOError].freeze

  class Error < StandardError
    attr_reader :status, :code

    def initialize(status, code, message)
      super("#{status} #{code}: #{message}")
      @status = status
      @code = code
    end
  end

  def self.post(path, payload, idempotency_key: nil)
    (1..ATTEMPTS).each do |attempt|
      begin
        res = request(path, payload, idempotency_key)
      rescue *NETWORK_ERRORS
        # Network error or timeout: the idempotency key makes a retry safe.
        raise if attempt == ATTEMPTS
        sleep backoff(attempt)
        next
      end

      body = (JSON.parse(res.body) rescue {})
      return body if res.is_a?(Net::HTTPSuccess)

      status = res.code.to_i
      name = body["name"] || "unknown_error"
      retryable = status >= 500 ||
                  (status == 429 && name != "monthly_quota_exceeded") ||
                  name == "concurrent_idempotent_requests"
      raise Error.new(status, name, body["message"]) if !retryable || attempt == ATTEMPTS

      sleep(res["retry-after"] ? res["retry-after"].to_f : backoff(attempt))
    end
  end

  def self.request(path, payload, idempotency_key)
    uri = URI("#{API_URL}#{path}")
    headers = {
      "Authorization" => "Bearer #{ENV.fetch('CRESCA_API_KEY')}",
      "Content-Type" => "application/json"
    }
    headers["Idempotency-Key"] = idempotency_key if idempotency_key
    Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 10) do |http|
      http.post(uri.path, payload.to_json, headers)
    end
  end

  # 1s, 2s, 4s... plus up to 250 ms of jitter.
  def self.backoff(attempt)
    2**(attempt - 1) + rand / 4
  end
end
```

## Step 2: send email with Ruby on Rails through a delivery method

A delivery method is any class with `initialize(settings)` and `deliver!(mail)`. This one turns the `Mail::Message` that ActionMailer built into an API request:

```ruby
# lib/cresca_delivery.rb: an ActionMailer delivery method for the Cresca API.
require_relative "cresca"

class CrescaDelivery
  attr_reader :settings

  def initialize(settings)
    @settings = settings
  end

  def deliver!(mail)
    payload = {
      from: mail[:from].to_s,
      to: mail.to,
      cc: mail.cc,
      bcc: mail.bcc,
      reply_to: mail.reply_to,
      subject: mail.subject,
      html: body_for(mail, "text/html"),
      text: body_for(mail, "text/plain")
    }.compact

    attachments = mail.attachments.map do |attachment|
      {
        filename: attachment.filename,
        content: [attachment.decoded].pack("m0"),
        content_type: attachment.mime_type
      }
    end
    payload[:attachments] = attachments if attachments.any?

    Cresca.post("/emails", payload, idempotency_key: mail.header["X-Idempotency-Key"]&.value)
  end

  private

  def body_for(mail, mime_type)
    if mail.multipart?
      part = mime_type == "text/html" ? mail.html_part : mail.text_part
      part&.decoded
    elsif (mail.mime_type || "text/plain") == mime_type
      mail.body.decoded
    end
  end
end
```

Register it and switch production over:

```ruby
# config/initializers/cresca.rb
require Rails.root.join("lib/cresca_delivery").to_s

ActionMailer::Base.add_delivery_method :cresca, CrescaDelivery
```

```ruby
# config/environments/production.rb
config.action_mailer.delivery_method = :cresca
config.action_mailer.raise_delivery_errors = true
config.action_mailer.default_url_options = { host: "acme.com", protocol: "https" }
```

If your app calls `config.autoload_lib` in config/application.rb (the default for apps created with Rails 7.1 or later), add both files to its ignore list, for example `config.autoload_lib(ignore: %w[assets tasks cresca.rb cresca_delivery.rb])`, because the initializer loads them itself.

Set `CRESCA_API_KEY` in your production environment, or read it from Rails credentials in `lib/cresca.rb`. Keep `:letter_opener` or `:test` in development and test.

## Step 3: send email as usual

Mailers do not change. The `from` default must be an active sender on your verified domain:

```ruby
class UserMailer < ApplicationMailer
  default from: "Acme <hello@acme.com>"

  def welcome
    @user = params[:user]
    headers["X-Idempotency-Key"] = "welcome/#{@user.id}"
    mail(to: @user.email, subject: "Welcome to Acme")
  end
end

UserMailer.with(user: user).welcome.deliver_later
```

The `X-Idempotency-Key` header never leaves your app: the delivery method reads it and sends it to Cresca as the `Idempotency-Key` request header, not as an email header. If Active Job retries the delivery after a timeout, Cresca returns the first result instead of sending a second email. Mailer views auto-escape with `<%= %>`, so user input is safe.

## Attachments

Attachments are sent inline as base64: up to 20 files and 7 MB in total per email. Each item has `filename`, `content` and optionally `content_type`. Set `content_id` to show an inline image with `<img src="cid:logo">`. Executable types such as .exe and .js are refused, and attaching by URL is not supported yet.

ActionMailer attachments just work: `attachments["invoice.pdf"] = pdf_bytes` in the mailer is sent as a base64 attachment by the delivery method. Inline attachments (`attachments.inline`) are sent as regular attachments by this simple version; add `content_id` from `attachment.content_id` if you need inline images.

## Send a template with variables

Instead of building HTML in your code, you can design the email in Cresca's template editor, mark the parts your app fills in with triple braces such as `{{{RESET_LINK}}}`, and publish it. Your app then sends only the template alias and the values. Sends always use the *published* version, so you can edit a draft without touching live email. Values are HTML-escaped, so a user's name can never change your markup. A missing variable with no fallback returns `422 validation_error` naming it. More in [templates and variables](https://www.cresca.xyz/docs/templates).

Some emails are better designed in Cresca than in a mailer view, for example ones a designer or marketer edits. Call the client directly for those:

```ruby
Cresca.post("/emails", {
  to: user.email,
  template: {
    id: "password-reset", # the template's alias or id
    variables: { NAME: user.first_name, RESET_LINK: edit_password_url(token: token) }
  }
}, idempotency_key: "password-reset/#{reset_request.id}")
```

## Send a batch of up to 100 emails

`POST /emails/batch` takes a JSON array of up to 100 emails, each with the same fields as a single send except `attachments` and `scheduled_at`. It is all or nothing: if one email is invalid, nothing is sent and the error message names the failing email by its zero-based index. The response is `{"data": [{"id": "..."}, ...]}` in the same order. One request counts as one request against your rate limit, so batching is how you send thousands of emails per minute.

```ruby
User.where(wants_summary: true).find_in_batches(batch_size: 100).with_index do |users, index|
  emails = users.map do |user|
    {
      from: "Acme <hello@acme.com>",
      to: user.email,
      subject: "Your weekly summary",
      html: ApplicationController.render(template: "summaries/weekly", layout: false, assigns: { user: user })
    }
  end
  Cresca.post("/emails/batch", emails, idempotency_key: "weekly-summary/#{Date.current}/#{index}")
end
```

Run this from a scheduled job, not a web request. Each chunk of 100 is one API request with its own idempotency key.

## Why not SMTP settings?

Rails' `:smtp` delivery method works with any SMTP server, but SMTP errors arrive as Ruby exceptions with little detail, and there is no way to make a retry idempotent. The delivery method above gets you JSON errors with a stable `name` and idempotent retries. Cresca does not offer an SMTP relay yet, so with Cresca you use the API as shown here.

## Errors to handle when you send email with Rails

These are the errors a sending app actually meets. The full list is on the [errors page](https://www.cresca.xyz/docs/errors).

| Status | name | What it means | Retry? |
| --- | --- | --- | --- |
| 403 | `invalid_from_address` | The from address is not an active sender on a verified domain. | No, fix the sender |
| 422 | `validation_error` | A field is invalid, or a template variable is missing. | No, fix the request |
| 422 | `missing_required_field` | No to, from or subject (and no template that supplies them). | No |
| 429 | `rate_limit_exceeded` | More requests per second than your plan allows. | Yes, after retry-after |
| 429 | `monthly_quota_exceeded` | The plan's email allowance is used up. | No |
| 409 | `concurrent_idempotent_requests` | The same Idempotency-Key is still being processed. | Yes, shortly |
| 5xx | `application_error` | A problem on Cresca's side. | Yes, with backoff |

Recipients on your suppression list (earlier hard bounces, complaints and unsubscribes) are skipped, but the request still succeeds. See [suppression](https://www.cresca.xyz/docs/suppression).

With `raise_delivery_errors = true`, a failed send raises `Cresca::Error`, so Active Job can retry it and your error tracker sees it.

## Check that the email was delivered

A `200` response means Cresca accepted the email, not that it reached the inbox. Open [Transactional](https://app.cresca.xyz/transactional) in the app to see each email's timeline, or look it up with a full-access key:

```bash
curl https://api.cresca.xyz/api/v1/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c \
  -H "Authorization: Bearer $CRESCA_API_KEY"
```

The `last_event` field moves from `queued` to `sent` and then `delivered` or `bounced`. See [email lifecycle](https://www.cresca.xyz/docs/email-lifecycle) for every status.

## Frequently asked questions

### How do I send email with Rails without SMTP?

Register a custom ActionMailer delivery method that calls an email API, then set config.action\_mailer.delivery\_method to it. The CrescaDelivery class in this tutorial converts each message into a request to https://api.cresca.xyz/api/v1/emails, so existing mailers keep working.

### Do deliver\_later and Active Job retries work?

Yes. deliver\_later enqueues the mailer as usual, and the job calls the delivery method. Set an X-Idempotency-Key header in the mailer so that if the job retries after a timeout, Cresca returns the first result instead of sending again.

### Is there an official Cresca SDK for Rails?

No, and you do not need one. The API is plain HTTPS and JSON, so the HTTP client you already use in Rails works. The helper in this tutorial is about 40 lines you can copy into your project and own.

### Can I preview emails before switching?

Yes. Mailer previews in test/mailers/previews do not depend on the delivery method, so they work the same way. Send a real test by running a mailer from the Rails console in an environment that uses the :cresca delivery method.

### How many emails can I send per request?

One email can have up to 50 recipients across to, cc and bcc, and a batch request carries up to 100 emails. Requests per second depend on your plan: 2 on Free, 10 on Professional, 20 on Premium, 50 on Ultra and 100 on Enterprise. Each recipient counts as one email against the monthly allowance.

### Is Cresca free to try?

Yes. The Free plan gives you 30 days, 50 contacts and 50 emails with no credit card, which is enough to build and test your integration. API email shares your plan's email allowance with campaigns; paid plans are on the pricing page.

## The same tutorial in other languages

[Node.js](https://www.cresca.xyz/blog/send-email-nodejs) · [Python](https://www.cresca.xyz/blog/send-email-python) · [PHP](https://www.cresca.xyz/blog/send-email-php) · [Go](https://www.cresca.xyz/blog/send-email-golang) · [Ruby](https://www.cresca.xyz/blog/send-email-ruby) · [Java](https://www.cresca.xyz/blog/send-email-java) · [C# and .NET](https://www.cresca.xyz/blog/send-email-csharp) · [Next.js](https://www.cresca.xyz/blog/send-email-nextjs) · [Laravel](https://www.cresca.xyz/blog/send-email-laravel) · [Django](https://www.cresca.xyz/blog/send-email-django)

For every field of the Emails API with cURL examples, read the [send email API guide](https://www.cresca.xyz/blog/send-email-api). For the big picture, start with the [transactional email API overview](https://www.cresca.xyz/blog/transactional-email-api). Still deciding how to send? Read [SMTP vs API](https://www.cresca.xyz/blog/smtp-vs-api-sending).

## Start sending

Verify a domain, create a sending-access key and send your first email with Rails in a few minutes. **[Try Cresca free for 30 days](https://app.cresca.xyz/signup)**, no credit card required, then follow the [quickstart](https://www.cresca.xyz/docs/quickstart) or browse the [API docs](https://www.cresca.xyz/docs).
