Home
/
Blog
/
iMessage API in Elixir
April 5, 2026
10 min read
Nikita Jerschow

iMessage API in Elixir — Send Blue Bubbles with Phoenix & Tesla (2026)

Elixir's actor model and OTP fault-tolerance make it a natural fit for high-reliability messaging systems. Sendblue exposes a REST API that Phoenix applications can call to deliver iMessages at scale — with pattern matching on responses, supervised GenServers processing webhooks, and built-in retry logic that never loses a message.

Why iMessage for Elixir applications?

iMessage reaches 98% of Apple devices as the default messaging app, with end-to-end encryption, read receipts, and typing indicators included. Blue bubbles consistently achieve 30–45% response rates versus 6% for email — a meaningful difference for customer-facing Elixir backends.

Sendblue's REST API sits in front of all the Apple complexity. Your Elixir application makes a standard HTTPS POST; Sendblue handles registration, authentication, and delivery. No Mac infrastructure required. Typical use cases for Elixir developers:

  • Phoenix-based SaaS apps sending onboarding sequences via iMessage
  • Real-time alerts from Erlang/OTP monitoring systems
  • Healthcare platforms (Sendblue is HIPAA compliant) sending appointment reminders
  • High-throughput notification pipelines using Broadway and GenStage

Add dependencies to mix.exs

Add Tesla as your HTTP client and Jason for JSON encoding. Tesla's middleware system makes it easy to attach headers, encode JSON, and handle retries in a composable way:

defp deps do [ {:tesla, "~> 1.9"}, {:hackney, "~> 1.20"}, # HTTP adapter for Tesla {:jason, "~> 1.4"}, # Optional: for Phoenix webhook endpoint {:phoenix, "~> 1.7"}, {:plug_cowboy, "~> 2.7"} ] end

Run mix deps.get to install. Store your credentials in config/runtime.exs (read at boot from environment variables, not compiled in):

# config/runtime.exs config :my_app, :sendblue, api_key_id: System.fetch_env!("SENDBLUE_API_KEY_ID"), api_secret_key: System.fetch_env!("SENDBLUE_API_SECRET_KEY")

Build a Tesla client module

Define a module that uses Tesla's use Tesla macro and attaches your API keys as headers via middleware. This approach is idiomatic Elixir — the middleware stack is declared once, and every function in the module uses it automatically:

defmodule MyApp.Sendblue do use Tesla plug Tesla.Middleware.BaseUrl, "https://api.sendblue.co" plug Tesla.Middleware.JSON, engine: Jason plug Tesla.Middleware.Headers, [ {"sb-api-key-id", Application.compile_env(:my_app, [:sendblue, :api_key_id], "")}, {"sb-api-secret-key", Application.compile_env(:my_app, [:sendblue, :api_secret_key], "")} ] plug Tesla.Middleware.Retry, delay: 500, max_retries: 3, max_delay: 4_000, should_retry: fn {:ok, %{status: status}} when status in [429, 500, 502, 503] -> true {:error, _} -> true _ -> false end adapter Tesla.Adapter.Hackney @doc """ Send an iMessage to a phone number. ## Parameters - number: E.164 format phone number, e.g. "+14155551234" - content: Text body of the message - opts: Keyword list of optional params (:send_style, :media_url) ## Returns {:ok, %{status: ..., message_handle: ...}} | {:error, reason} """ def send_message(number, content, opts \\ []) do payload = %{number: number, content: content} |> maybe_put(:send_style, Keyword.get(opts, :send_style)) |> maybe_put(:media_url, Keyword.get(opts, :media_url)) case post("/api/send-message", payload) do {:ok, %{status: 200, body: body}} -> {:ok, %{ status: body["status"], message_handle: body["messageHandle"], number: body["number"] }} {:ok, %{status: status, body: body}} -> {:error, "HTTP #{status}: #{inspect(body)}"} {:error, reason} -> {:error, reason} end end defp maybe_put(map, _key, nil), do: map defp maybe_put(map, key, value), do: Map.put(map, key, value) end

The maybe_put/3 helper cleanly handles optional fields without cluttering the payload with nil values that the API would ignore anyway.

Pattern match on API responses

Elixir's pattern matching makes response handling expressive and exhaustive. Call send_message/3 from a Phoenix controller, LiveView action, or background job:

defmodule MyApp.NotificationService do require Logger alias MyApp.Sendblue def notify_user(phone, message) do case Sendblue.send_message(phone, message) do {:ok, %{status: "QUEUED", message_handle: handle}} -> Logger.info("Message queued: #{handle}") {:ok, handle} {:ok, %{status: "SENT", message_handle: handle}} -> Logger.info("Message sent: #{handle}") {:ok, handle} {:ok, %{status: status}} -> Logger.warning("Unexpected status: #{status}") {:error, :unexpected_status} {:error, reason} -> Logger.error("Failed to send to #{phone}: #{inspect(reason)}") {:error, reason} end end def send_media(phone, caption, media_url) do case Sendblue.send_message(phone, caption, media_url: media_url) do {:ok, result} -> {:ok, result} {:error, reason} -> {:error, reason} end end end

Process webhooks with a supervised GenServer

Configure a webhook URL in the Sendblue dashboard. When a message is delivered or a recipient replies, Sendblue POSTs a JSON payload to your server. Use a Phoenix endpoint to receive it and a supervised GenServer to process events asynchronously — keeping your webhook handler fast and non-blocking:

# lib/my_app/sendblue_webhook_worker.ex defmodule MyApp.SendblueWebhookWorker do use GenServer require Logger def start_link(_opts) do GenServer.start_link(__MODULE__, :ok, name: __MODULE__) end def process(payload) do GenServer.cast(__MODULE__, {:process, payload}) end @impl true def init(:ok) do {:ok, %{processed: 0}} end @impl true def handle_cast({:process, %{"isOutbound" => false, "content" => content, "fromNumber" => from}}, state) do Logger.info("Inbound reply from #{from}: #{content}") # Route to your reply-handling logic here handle_inbound_reply(from, content) {:noreply, %{state | processed: state.processed + 1}} end def handle_cast({:process, %{"isOutbound" => true, "status" => status, "messageHandle" => handle}}, state) do Logger.info("Delivery update — handle: #{handle}, status: #{status}") # Update message status in your database {:noreply, state} end def handle_cast({:process, payload}, state) do Logger.debug("Unhandled webhook payload: #{inspect(payload)}") {:noreply, state} end defp handle_inbound_reply(_from, _content) do # Your reply logic — trigger an AI response, route to support, etc. :ok end end
# Add to your supervision tree in lib/my_app/application.ex children = [ MyAppWeb.Endpoint, MyApp.SendblueWebhookWorker # <-- add this ] Supervisor.start_link(children, strategy: :one_for_one)
# lib/my_app_web/controllers/webhook_controller.ex defmodule MyAppWeb.WebhookController do use MyAppWeb, :controller def sendblue(conn, params) do MyApp.SendblueWebhookWorker.process(params) send_resp(conn, 200, "ok") end end # router.ex post "/webhooks/sendblue", WebhookController, :sendblue

Because the GenServer processes events asynchronously with cast, the webhook handler returns 200 immediately — preventing Sendblue from retrying due to slow response times.

Use HTTPoison as an alternative to Tesla

If your project already uses HTTPoison, you can call Sendblue directly without adding Tesla. The approach is more explicit but equally straightforward:

defmodule MyApp.SendblueHTTPoison do @base_url "https://api.sendblue.co/api/send-message" defp headers do config = Application.get_env(:my_app, :sendblue) [ {"Content-Type", "application/json"}, {"sb-api-key-id", config[:api_key_id]}, {"sb-api-secret-key", config[:api_secret_key]} ] end def send_message(number, content) do body = Jason.encode!(%{number: number, content: content}) case HTTPoison.post(@base_url, body, headers()) do {:ok, %HTTPoison.Response{status_code: 200, body: resp_body}} -> {:ok, Jason.decode!(resp_body)} {:ok, %HTTPoison.Response{status_code: code, body: resp_body}} -> {:error, "HTTP #{code}: #{resp_body}"} {:error, %HTTPoison.Error{reason: reason}} -> {:error, reason} end end end

Frequently asked questions

Does Sendblue work with Elixir?
Yes. Sendblue is a standard REST API over HTTPS. Any HTTP client in Elixir — Tesla, HTTPoison, or Finch — can call it. You POST to https://api.sendblue.co/api/send-message with a JSON body and your sb-api-key-id and sb-api-secret-key headers. Sendblue handles all Apple infrastructure on your behalf.

What are the rate limits?
Sandbox accounts are limited to 5 messages per minute. Paid plans support higher throughput. Elixir's concurrency model makes it easy to control send rates — use a GenServer with a rate-limited queue or a process pool. OTP's built-in back-pressure mechanisms integrate naturally with Sendblue's API.

How do I handle concurrent messaging in Elixir?
Elixir excels here. Spawn a supervised Task for each message, or use a GenStage/Broadway pipeline for backpressure-aware processing. For high volume, a pooled connection approach with Finch as the HTTP adapter gives maximum throughput. Use a token bucket GenServer to respect rate limits without blocking callers.

How does error handling work with Elixir's fault tolerance?
Pattern match on {:ok, response} and {:error, reason} tuples from your HTTP client. Wrap your messaging logic in a supervised GenServer so crashes are automatically restarted by the OTP supervisor. For transient network errors, use Process.send_after/3 to schedule retries without blocking a process.

Next steps

  • API reference — Full Sendblue endpoint documentation including group messaging, typing indicators, and read receipts
  • iMessage API overview — How Sendblue's iMessage infrastructure works under the hood
  • Webhook docs — Complete webhook payload schema and retry behavior

Ready to send iMessages from Elixir?

Free sandbox, no credit card required. Get API keys in minutes.

Get API Access