Official Ruby SDK for MillionSend — a self-hostable, Resend-compatible email API on AWS SES.
The API is wire-compatible with Resend, and this gem deliberately mirrors the shape of
resend, so migrating is mostly a find-and-replace:
swap the constant, set base_url to your instance.
gem install millionsendOr in a Gemfile:
gem "millionsend"Requires Ruby 3.0+. Only the standard library is used at runtime (net/http, json).
require "millionsend"
Millionsend.api_key = "ms_123"
Millionsend.base_url = "https://mail.acme.dev"
email = Millionsend::Emails.send(
from: "Acme <onboarding@acme.dev>",
to: "delivered@resend.dev",
subject: "Hello from MillionSend",
html: "<strong>It works!</strong>"
)
puts email[:id]Every call returns a symbol-keyed Hash on success and raises a Millionsend::Error
on any non-2xx response (see Error handling).
Millionsend.api_key = "ms_123" # falls back to ENV["MILLIONSEND_API_KEY"]
Millionsend.base_url = "https://mail.acme.dev" # falls back to ENV["MILLIONSEND_BASE_URL"],
# then http://localhost:3001MillionSend is self-hosted, so there is no cloud default — set base_url to your
deployment in production. An explicitly assigned value always wins over the environment.
Params are symbol-keyed hashes and map straight to the wire (Ruby's snake_case is already
the wire's snake_case: reply_to, scheduled_at, audience_id).
Millionsend::Emails.send(payload, idempotency_key: "order-42") # POST /emails
Millionsend::Emails.get(id) # GET /emails/:id
Millionsend::Emails.cancel(id) # POST /emails/:id/cancel (scheduled only)
Millionsend::Batch.send([payload_a, payload_b], idempotency_key: "run-7") # up to 100to, cc, bcc and reply_to accept either a string or an array. Emails.create is
an alias of Emails.send (as is Batch.create), mirroring Resend.
audience = Millionsend::Audiences.create(name: "Registered users")
Millionsend::Audiences.list(limit: 20, after: cursor)
Millionsend::Audiences.get(id)
Millionsend::Audiences.remove(id)
Millionsend::Contacts.create(audience_id: audience[:id], email: "ada@acme.dev",
first_name: "Ada", properties: { plan: "pro" })
Millionsend::Contacts.get("ada@acme.dev", audience_id: audience[:id]) # by id or email
Millionsend::Contacts.get(contact_id) # top-level, by id
Millionsend::Contacts.update(id: contact_id, unsubscribed: true, first_name: nil) # nil clears
Millionsend::Contacts.remove("ada@acme.dev", audience_id: audience[:id])
Millionsend::Contacts.list(audience_id: audience[:id], limit: 50)
# Topic subscriptions (granular unsubscribe) — mirrors resend's contacts.topics.update
Millionsend::Contacts.topics_update("ada@acme.dev", [{ id: topic_id, subscription: "opt_out" }])Contacts are addressable by id or email; when an update hash carries both, the email wins.
Omit audience_id: to use the top-level /contacts endpoints.
Millionsend::Topics.create(name: "Product updates", default_subscription: "opt_in")
Millionsend::Topics.get(id)
Millionsend::Topics.list # bare { data: [...] } — topics are unpaginated
Millionsend::Topics.remove(id)broadcast = Millionsend::Broadcasts.create(
audience_id: audience[:id],
from: "Acme <news@acme.dev>",
subject: "Launch",
html: "<p>Hi {{{FIRST_NAME|there}}}</p>"
)
Millionsend::Broadcasts.list
Millionsend::Broadcasts.get(broadcast[:id])
Millionsend::Broadcasts.update(broadcast[:id], subject: "Launch 🚀") # draft only
Millionsend::Broadcasts.send(broadcast[:id], scheduled_at: "2026-09-01T09:00:00Z") # omit to send now
Millionsend::Broadcasts.cancel(broadcast[:id]) # scheduled only
Millionsend::Broadcasts.remove(broadcast[:id]) # draft onlyDynamic segments are a saved filter over an audience's contacts — a MillionSend superset with
no Resend equivalent, served under /segments2.
segment = Millionsend::Segments.create(
name: "Pro plan",
audience_id: audience[:id],
filter: { match: "all", conditions: [{ field: "property:plan", op: "equals", value: "pro" }] }
)
Millionsend::Segments.get(segment[:id]) # includes a live contact_count
Millionsend::Segments.list
Millionsend::Segments.update(segment[:id], name: "Pro tier")
Millionsend::Segments.remove(segment[:id])No { data, error } tuple — a non-2xx response raises. The base class is Millionsend::Error,
which carries #status_code, #name (the stable snake_case discriminant), and #message.
Subclasses are keyed on name, so you can rescue a specific failure:
begin
Millionsend::Emails.get(id)
rescue Millionsend::NotFoundError => e
warn "no such email: #{e.message}"
rescue Millionsend::Error => e
warn "#{e.name} (#{e.status_code || 'transport'}): #{e.message}"
endSubclasses: ValidationError, NotFoundError, RestrictedApiKeyError, SendingPausedError,
InvalidIdempotentRequestError, and ApplicationError (the fallback). Client-side and
transport failures that never reached the API raise with #status_code == nil.
- require "resend"
- Resend.api_key = "re_123"
- Resend::Emails.send(from: "...", to: "...", subject: "Hi", html: "<p>hi</p>")
+ require "millionsend"
+ Millionsend.api_key = "ms_123"
+ Millionsend.base_url = "https://mail.acme.dev"
+ Millionsend::Emails.send(from: "...", to: "...", subject: "Hi", html: "<p>hi</p>")Method names, nesting and payloads match. Notes:
- Domains and API keys are managed in the MillionSend dashboard, not via the API, so there
are no
Domains/ApiKeysresources here. - Resend's
Contacts.topics.updatebecomesMillionsend::Contacts.topics_update(Ruby has no nested-module method on a module function). Millionsend::Segmentsis the distinct dynamic-filter feature (/segments2), not Resend's audiences alias. UseMillionsend::Audiencesfor a straight port.
MIT