millionsend
Official Elixir SDK for MillionSend — a self-hostable, Resend-compatible email API on AWS SES.
The API is wire-compatible with Resend, and this SDK mirrors the shape of
resend-elixir, so migrating is mostly a
find-and-replace: swap the module prefix and point base_url at your instance.
Install
# mix.exs
def deps do
[{:millionsend, "~> 0.1"}]
end
Requires Elixir 1.15+. HTTP is handled by Req.
Quickstart
config :millionsend, MillionSend.Client,
api_key: System.get_env("MILLIONSEND_API_KEY"),
base_url: "https://mail.acme.dev"
case MillionSend.Emails.send(%{
from: "Acme <onboarding@acme.dev>",
to: "delivered@resend.dev",
subject: "Hello from MillionSend",
html: "<strong>It works!</strong>"
}) do
{:ok, email} -> IO.puts("sent #{email.id}")
{:error, error} -> IO.puts("#{error.name}: #{error.message}")
end
Configuration
Two interchangeable styles, use whichever fits:
# 1. Application env — every call works without an explicit client.
config :millionsend, MillionSend.Client,
api_key: "ms_123",
base_url: "https://mail.acme.dev"
MillionSend.Emails.get("email-id")
# 2. An explicit client passed as the first argument.
client = MillionSend.client(api_key: "ms_123", base_url: "https://mail.acme.dev")
MillionSend.Emails.get(client, "email-id")
Resolution precedence for each option: explicit MillionSend.client/1 opts →
config :millionsend, MillionSend.Client → OS environment
(MILLIONSEND_API_KEY, MILLIONSEND_BASE_URL) → defaults.
api_keyis required; missing everywhere raisesArgumentError.base_urldefaults tohttp://localhost:3001. MillionSend is self-hosted, so set this to your deployment in production.user_agent(optional) appends a suffix after the SDK's own User-Agent token.http_client(optional) swaps the HTTP layer — any module implementing theMillionSend.HTTPbehaviour (used to stub requests in tests).
Every function accepts an optional leading client argument; omit it to use the
configured default.
Errors
No function raises for an API error — each returns {:ok, struct} or
{:error, %MillionSend.Error{}}. The error's name is a stable snake_case code
you can match on ("validation_error", "not_found", "restricted_api_key",
"sending_paused", …). Client-side and transport failures carry
status_code: nil.
case MillionSend.Emails.get(id) do
{:ok, email} -> email
{:error, %MillionSend.Error{name: "not_found"}} -> :gone
{:error, error} -> {:error, error.status_code, error.message}
end
MillionSend.Error is also an exception, so you can raise/Exception.message/1
it if you prefer to bubble failures up.
Resources
Emails
MillionSend.Emails.send(payload) # POST /emails
MillionSend.Emails.send(payload, idempotency_key: key) # with idempotency
MillionSend.Emails.get(id) # GET /emails/:id
MillionSend.Emails.cancel(id) # POST /emails/:id/cancel (scheduled only)
MillionSend.Emails.send_batch([a, b], idempotency_key: key) # POST /emails/batch (up to 100)
Input maps are snake_case (:reply_to, :scheduled_at); :to/:cc/:bcc/
:reply_to accept a string or a list of strings.
Audiences & contacts
{:ok, 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: aud, email: "ada@acme.dev",
first_name: "Ada", properties: %{plan: "pro"}})
MillionSend.Contacts.get(%{audience_id: aud, email: "ada@acme.dev"}) # id or email (email wins)
MillionSend.Contacts.get("contact-uuid") # bare id works too
MillionSend.Contacts.update(%{id: id, unsubscribed: true, first_name: nil}) # nil clears
MillionSend.Contacts.remove(%{email: "ada@acme.dev"})
MillionSend.Contacts.list(audience_id: aud, limit: 50)
# Topic subscriptions (granular unsubscribe)
MillionSend.Contacts.update_topics(%{email: "ada@acme.dev",
topics: [%{id: topic_id, subscription: :opt_out}]})
Topics
MillionSend.Topics.create(%{name: "Product updates", default_subscription: :opt_in})
MillionSend.Topics.get(id)
MillionSend.Topics.list() # a plain list — topics are unpaginated
MillionSend.Topics.remove(id)
Broadcasts
{:ok, broadcast} = MillionSend.Broadcasts.create(%{
audience_id: aud, from: "Acme <news@acme.dev>", subject: "Launch",
html: "<p>Hi {{{FIRST_NAME|there}}}</p>"
})
MillionSend.Broadcasts.list()
MillionSend.Broadcasts.get(id)
MillionSend.Broadcasts.update(id, %{subject: "Launch 🚀"}) # draft only
MillionSend.Broadcasts.send(id, scheduled_at: "2026-09-01T09:00:00Z") # omit to send now
MillionSend.Broadcasts.cancel(id) # scheduled only
MillionSend.Broadcasts.remove(id) # draft only
Segments (MillionSend extension)
Dynamic segments are a saved filter over an audience's contacts — a MillionSend
superset with no Resend equivalent (wire path /segments2).
MillionSend.Segments.create(%{
name: "Pro plan",
audience_id: aud,
filter: %{match: :all, conditions: [%{field: "property:plan", op: "equals", value: "pro"}]}
})
MillionSend.Segments.get(id) # includes a live contact_count
MillionSend.Segments.list()
MillionSend.Segments.update(id, %{name: "Pro tier"})
MillionSend.Segments.remove(id)
Migrating from Resend
- {:ok, email} = Resend.Emails.send(%{from: ..., to: ..., subject: ..., html: ...})
+ {:ok, email} = MillionSend.Emails.send(%{from: ..., to: ..., subject: ..., html: ...})
- config :resend, api_key: "re_123"
+ config :millionsend, MillionSend.Client, api_key: "ms_123", base_url: "https://mail.acme.dev"
Module names, function names and payloads match. Notes:
- Domains and API keys are managed in the MillionSend dashboard, not via the
API, so there are no
Domains/ApiKeysmodules here. - Resend's segments are an alias of audiences; MillionSend's
Segmentsare the distinct dynamic-filter feature. UseAudiencesfor a straight port.
Testing against a real instance
The suite is fully mocked. An opt-in end-to-end test runs only when
MILLIONSEND_API_KEY is set (and MILLIONSEND_BASE_URL if not localhost):
MILLIONSEND_API_KEY=ms_... MILLIONSEND_BASE_URL=http://localhost:3001 mix test
License
MIT