# Send Email with Ruby: A Complete API Tutorial

> Send email with Ruby using Net::HTTP and an email API: HTML, templates, attachments, batches and safe retries with no gems, plus a Sidekiq job example.

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

To **send email with Ruby** you no longer need `Net::SMTP` or a mail server. An email API takes one HTTPS request with Ruby's built-in `Net::HTTP`: Cresca accepts the email, returns an id and delivers it from your own verified domain within seconds. This tutorial builds a small client with no gems, then sends HTML, templates, attachments and batches, and runs the send from a Sidekiq job.

**TL;DR**

- `Net::HTTP.post` JSON to `https://api.cresca.xyz/api/v1/emails` with your key as a bearer token.
- Standard library only: net/http and json.
- Retry 429, 5xx and network errors with an `Idempotency-Key`.
- Using Rails? There is a separate guide that plugs Cresca into ActionMailer.

## 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`.

Any supported Ruby version works; the examples use only the standard library. The full setup, with screenshots of each step, is in the [email API quickstart](https://www.cresca.xyz/docs/quickstart).

## Send email with Ruby: the basic request

```ruby
require "net/http"
require "json"

res = Net::HTTP.post(
  URI("https://api.cresca.xyz/api/v1/emails"),
  {
    from: "Acme <hello@acme.com>",
    to: "ana@example.com",
    subject: "Welcome to Acme",
    html: "<p>Hi Ana, your account is ready.</p>"
  }.to_json,
  "Authorization" => "Bearer #{ENV.fetch('CRESCA_API_KEY')}",
  "Content-Type" => "application/json"
)

body = JSON.parse(res.body)
raise "#{body['name']}: #{body['message']}" unless res.is_a?(Net::HTTPSuccess)
puts body["id"]
```

The response is `200 OK` with the email's id, for example `{"id": "4ef9a417-..."}`. If you send only `html`, Cresca generates the plain-text part; pass `text` to control it yourself.

## A production-ready Ruby client with retries

The basic request is fine for a script. In production you want three more things, and the `Cresca` module below adds all of them:

- **An `Idempotency-Key` header.** If the connection drops after Cresca accepted the email, a retry with the same key returns the first result instead of sending a second email. Keys last 24 hours. Build them from the event, such as `welcome/user-123` or `invoice/inv-2026-0042`, never from the time or a random value. See [idempotency](https://www.cresca.xyz/docs/idempotency).
- **Retries only where they help.** Retry network errors, timeouts, `429 rate_limit_exceeded` and `5xx` with exponential backoff, and wait for the `retry-after` header when Cresca sends it. Do not retry other `4xx` errors: the same request fails the same way.
- **Errors you can branch on.** Every error body has a stable `name` and a readable `message`, for example `{"statusCode": 422, "name": "validation_error", "message": "..."}`. Branch on `name`, because messages can be reworded.

```ruby
# 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
```

Using it:

```ruby
require "cgi"
require_relative "cresca"

sent = Cresca.post("/emails", {
  from: "Acme <hello@acme.com>",
  to: user.email,
  subject: "Welcome to Acme",
  html: "<p>Hi #{CGI.escapeHTML(user.first_name)}, your account is ready.</p>"
}, idempotency_key: "welcome/#{user.id}")

puts sent["id"]
```

`CGI.escapeHTML` keeps user input from changing your markup. Catch `Cresca::Error` and branch on `code`, for example to alert someone on `invalid_from_address`.

## 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).

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

## Add 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.

```ruby
pdf = File.binread("invoices/inv-2026-0042.pdf")

Cresca.post("/emails", {
  from: "Acme Billing <billing@acme.com>",
  to: customer.email,
  subject: "Your invoice INV-2026-0042",
  html: "<p>Thanks for your payment. Your invoice is attached.</p>",
  attachments: [{
    filename: "inv-2026-0042.pdf",
    content: [pdf].pack("m0"), # strict base64, no line breaks
    content_type: "application/pdf"
  }]
}, idempotency_key: "invoice/inv-2026-0042")
```

`pack("m0")` is strict base64 from core Ruby. `Base64.strict_encode64` does the same, but from Ruby 3.4 the `base64` library is a gem you must add to your Gemfile.

## 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
emails = users.map do |user|
  {
    from: "Acme <hello@acme.com>",
    to: user.email,
    subject: "Your weekly summary",
    html: render_summary(user)
  }
end

emails.each_slice(100).with_index do |chunk, index|
  result = Cresca.post("/emails/batch", chunk, idempotency_key: "weekly-summary/2026-10-27/#{index}")
  puts "Batch #{index}: #{result['data'].size} emails accepted"
end
```

## Send email from a Sidekiq job

Web requests should not wait on email. Put the send in a background job; if the job retries after a crash, the idempotency key makes sure the email still goes out only once.

```ruby
class WelcomeEmailJob
  include Sidekiq::Job

  def perform(user_id)
    user = User.find(user_id)
    Cresca.post("/emails", {
      to: user.email,
      template: { id: "welcome", variables: { NAME: user.first_name } }
    }, idempotency_key: "welcome/#{user.id}")
  end
end

WelcomeEmailJob.perform_async(user.id)
```

## Net::SMTP, the mail gem or an email API?

`Net::SMTP` and the mail gem are solid if you run an SMTP server. An API removes the SMTP login, TLS setup and connection errors, returns JSON errors with a stable `name`, and makes retries safe with idempotency keys. Cresca does not offer an SMTP relay yet, so with Cresca you use the API as shown. In a Rails app, read [send email with Ruby on Rails](https://www.cresca.xyz/blog/send-email-rails) to keep your mailers and send through Cresca.

## Errors to handle when you send email with Ruby

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).

## 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 Ruby without SMTP?

Call an email API with Net::HTTP. With Cresca you POST a JSON body with from, to, subject and html to https://api.cresca.xyz/api/v1/emails with your API key as a bearer token. No gems, SMTP server or password are needed.

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

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

### Can I use Faraday or HTTParty instead of Net::HTTP?

Yes. Any HTTP client works. Send the same JSON body and headers, and keep the same retry rules: retry network errors, 429 rate\_limit\_exceeded and 5xx responses with an Idempotency-Key, and never retry other 4xx errors.

### 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) · [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) · [Ruby on Rails](https://www.cresca.xyz/blog/send-email-rails)

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 Ruby 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).
