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.

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:

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