Your Phoenix app already knows the invoice is overdue, the order shipped, the shift starts in an hour. Getting that fact onto WhatsApp is where it stalls, usually behind a Business Solution Provider contract nobody wants to sign for a few hundred notifications a day.
Here is the plain version. A whatsapp api elixir integration is one authenticated POST to a REST endpoint, sending from the WhatsApp number you already own over WhatsApp Web or a managed 24/7 gateway. It is not Meta's Cloud API, so there are no templates, no Business verification, and no per-message billing anywhere below. Consent stays yours, and nobody can promise a number will never be actioned.
Can you send WhatsApp from Elixir on your own number, no Cloud API?
Yes. You POST to a REST endpoint, authenticate with a bearer key, and the message leaves from your linked WhatsApp account over WhatsApp Web or a hosted gateway. This is the whatsapp own number api model, not Meta's Cloud API, so no templates, no Business verification, and no per-message fees apply.
The distinction changes the whole integration. On the Cloud API path you register a number with Meta, get it approved, submit message templates for review, and pay per message: Meta deprecated conversation-based pricing on 1 July 2025, so it now bills per message sent. On the own-number path you send from a phone your customers already have saved, in plain text, with a bt_live_ key.
Elixir shares no runtime with the other languages in this series, but the wire shape is identical: it is the same REST call another typed backend makes. The Go guide, the Rust guide, and the Swift guide walk the same first send in their own idioms. This piece takes the idiomatic-Elixir path: Req and Jason for the call, pattern-matched {:ok, _} / {:error, _} tuples for the result, Oban for scheduling and retries, and OTP supervision so a crash never loses a send. The auth model is covered in the own-number REST API guide; this piece stays on the BEAM mechanics.
One thing from the start: sending from your own number is not a licence to blast. A loop firing unsolicited messages is the fastest way to get a number flagged under WhatsApp's Business Policy. Consent is yours to collect.
What do you need before your first send from Elixir?
Three things: a recent Elixir toolchain (Elixir 1.15+ on OTP 26 is a safe floor), a WhatsApp number already linked to your Blueticks account, and a bt_live_ key. The recipient goes in the URL path as an E.164 number like +15551234567, not in the JSON body. That single detail causes most first-attempt failures.
One honest note on dependencies. There is no official WhatsApp SDK for Elixir, and you do not need one. Req is a batteries-included HTTP client built on Finch that encodes a JSON body and decodes the response for you, and Jason handles the serialisation underneath. Both are small, widely used Hex packages, so a first send is {:req, "~> 0.5"} in mix.exs and nothing else.
The pre-flight, in order:
- Mint a key. Open dev.blueticks.co, sign in, create a key, copy it once. Read it at runtime with
System.fetch_env!("BLUETICKS_API_KEY"), never a committedconfig/runtime.exsliteral. - Link a number. Blueticks drives your number, not a Meta-provisioned one. If you already connected a phone in the app, you are done.
- Check what you do not need. No Meta Business verification, no message templates, no per-message billing.
And the trap that catches everyone. The endpoint is POST /v1/scheduled-messages/{chatId} on https://api.blueticks.co, and {chatId} is a path segment. A recipient is an E.164 number with a leading +, and a bare + in a path is ambiguous, so it has to be percent-encoded as %2B. You can also target a chat id directly, such as 120363...@g.us for a group. The same first send in shell, Python, and Node lives in the language-agnostic walkthrough if you want to sanity-check the request outside Elixir first.
What is the exact Blueticks /v1 send contract?
A single flat JSON body with a required type, the recipient in the path, bearer auth, and a success envelope. Here is the whole contract so nothing below is a surprise: type is one of text, media, or poll and is required even though it is validation-only, so a bare {"text":"hi"} is rejected with a 400. Text tops out at 4,096 characters.
- URL:
POST https://api.blueticks.co/v1/scheduled-messages/{chatId}, recipient in the path. - Auth:
Authorization: Bearer bt_live_.... - Body: flat JSON,
{"type":"text","text":"...","sendAt":"..."}. No nestedmedia{}orpoll{}. sendAt: optional, camelCase, an RFC 3339 timestamp with an explicit offset. Omit it to send now. The accepted window is roughly 10 seconds to 365 days ahead; outside that is a400.- Response: a
2xxreturns{"success":true,"data":{...}}, so the id lives atdata.id, a 24-character hex queue id, never at the top level. Decode the envelope. status:pending, thenconfirmed,received,read,played, orfailed.waMessageKey: an object, ornulluntil WhatsApp dispatches the message.
Those details come from the live /v1 contract, cross-checked at dev.blueticks.co/docs. Hold that shape in mind and the Elixir below reads as plain pattern matching.
How do you send a WhatsApp message from Elixir with Req and Jason?
Build a map, let Req encode it as JSON, set a bearer token, and POST to the escaped path. Req returns {:ok, %Req.Response{}} or {:error, exception}, and it decodes the JSON response body into a map for you, so the id is response.body["data"]["id"]. Omit sendAt and it sends immediately. This is the send whatsapp message elixir baseline, and it needs no vendor SDK.
# mix.exs: {:req, "~> 0.5"}
defmodule WhatsApp do
@base "https://api.blueticks.co/v1"
def send_text(recipient, text) do
# The recipient is a PATH segment: '+' -> %2B (see the escaping note below).
chat_id = URI.encode(recipient, &URI.char_unreserved?/1)
api_key = System.fetch_env!("BLUETICKS_API_KEY")
Req.post(
"#{@base}/scheduled-messages/#{chat_id}",
auth: {:bearer, api_key},
# `json:` encodes the map with Jason AND sets the content-type.
json: %{type: "text", text: text}
)
end
end
Call it from an iex -S mix session: WhatsApp.send_text("+15551234567", "Reminder: your invoice is due Friday."). You get back {:ok, %Req.Response{status: 200, body: %{"success" => true, "data" => %{"id" => "6a1f...c2", "status" => "pending", "waMessageKey" => nil}}}}.
Two Elixir-specific gotchas live in that one call. First, the path escape. URI.encode_www_form/1 is the function your fingers reach for, and it is wrong here: it encodes a space as + and is built for query strings and form bodies, not path segments, so it would mangle the recipient. Use URI.encode(recipient, &URI.char_unreserved?/1), which percent-encodes everything that is not an unreserved character, turning + into %2B and leaving a group id like 120363...@g.us safe. Second, never build the body by string interpolation. A single quote in a customer name breaks a hand-concatenated payload, while the json: option hands the map to Jason and escapes it correctly every time.

How do you handle the nullable waMessageKey and error tuples the Elixir way?
Pattern-match the result tuple and the body in one case, and treat waMessageKey as a value that is nil until dispatch. On a scheduled send the engine has not dispatched yet, so waMessageKey comes back null, which Jason decodes to nil. It fills in later as a map with fromMe, remote, id, _serialized, and participant, never a bare string. Read data["id"], the 24-hex queue id, as your handle meanwhile.
def deliver(recipient, text) do
with {:ok, %Req.Response{status: status, body: body}} when status in 200..299 <-
WhatsApp.send_text(recipient, text),
%{"success" => true, "data" => data} <- body do
{:ok, data}
else
{:ok, %Req.Response{status: status, body: body}} ->
{:error, {:http_status, status, body}} # a 4xx/5xx the server returned
{:error, exception} ->
{:error, {:transport, exception}} # connection refused, timeout, DNS
end
end
def handle(data) do
case data do
%{"waMessageKey" => nil, "id" => id} ->
{:queued, id} # accepted, not dispatched yet
%{"waMessageKey" => %{"_serialized" => serialized}, "id" => id} ->
{:dispatched, id, serialized} # WhatsApp has it
end
end
This is where Elixir pays off. The with chain reads top to bottom as the happy path, and anything that does not match falls through to the else with the actual failure in hand. There is no null to forget: %{"waMessageKey" => nil} and %{"waMessageKey" => %{...}} are two different clauses the compiler makes you consider. The trap in other languages, reaching into a key that is null on the first scheduled send, simply cannot compile into this shape. A 2xx with no data (an empty success envelope) lands in neither clause, so add a %{"success" => true} clause if you call endpoints that return one.
Ready to wire this into your Phoenix app? Grab a
bt_live_key and POST from the number you already own. No Meta Business verification, no message templates, no per-message fees. Paste the Req snippet into aniex -S mixsession, watch the first send land on your own WhatsApp in seconds, then drop it into an Oban job for scheduled, retried delivery.
How do you schedule, retry, and fan out sends with Oban, Task, and OTP?
Push the send off the request path into an Oban job, retry only transient failures, and cap any batch fan-out with Task.async_stream. Oban is a Postgres-backed job queue, so a job survives a node restart and retries with exponential backoff on its own. That is the whole game for an elixir whatsapp integration that survives real traffic.
defmodule MyApp.Workers.WhatsAppSend do
use Oban.Worker,
queue: :whatsapp,
max_attempts: 5,
# Dedup identical enqueues for 5 minutes so a double-insert is a no-op.
unique: [period: 300, keys: [:idem_key]]
@impl Oban.Worker
def perform(%Oban.Job{args: %{"to" => to, "text" => text, "idem_key" => idem}}) do
case MyApp.WhatsApp.deliver(to, text, idempotency_key: idem) do
{:ok, _data} ->
:ok
# A 400 is a bad body and fails identically forever: cancel, do not retry.
{:error, {:http_status, 400, _}} ->
{:cancel, "bad request, not retryable"}
# 429 and 5xx and transport errors are transient: return {:error, _}
# and Oban reschedules with backoff.
{:error, reason} ->
{:error, reason}
end
end
end
Two rules keep the retry safe. Retry only 429, 5xx, and transport errors; a 400 returned as {:cancel, _} stops Oban burning attempts on a body that will never succeed. And carry a stable Idempotency-Key so every retry reuses the identical value. The Blueticks API treats that header as at-most-once: resend the same key with an identical body and the original response replays, returning the same 24-hex id, so detect a replay by the id, not the status. A different body under the same key returns 409 Conflict, which means your key derivation has a bug. The key is capped at 64 characters and scoped per workspace. Derive it once from the business object ("order-4471-shipped") and put it in the job args, so Oban's own retries and your unique guard both reuse it. A fresh Ecto.UUID.generate() per attempt turns each retry into a new message.
For a batch, resist Enum.each with a bare Task.async per row. That spawns one unsupervised process per contact and fires them all at once, which is the burst pattern most likely to flag a number. Use Task.async_stream with a max_concurrency cap instead:
orders
|> Task.async_stream(
fn o -> MyApp.WhatsApp.enqueue(o.phone, "Order #{o.id} shipped", "order-#{o.id}-shipped") end,
max_concurrency: 4, # at most 4 in flight, not 500
timeout: 20_000
)
|> Enum.to_list()

The OTP point underneath this: put the real send inside the Oban worker, not a bare Task. A supervised Oban job is persisted and restartable, while an unsupervised Task that crashes takes the send with it and leaves nothing to retry. One production limit to design around: the free plan allows 5 requests per 6-hour window, shared across the REST and MCP surfaces, so a loop testing ten sends hits 429 on the sixth within a second. Any active subscription removes the ceiling.
How do you wrap this in a Phoenix context module?
Put the HTTP details behind a context module so your controllers and LiveViews call MyApp.WhatsApp.enqueue/3, not Req directly. A Phoenix context is the public boundary for a slice of your app, so the API key, the base URL, the path escaping, and the Oban insert all live in one module and the rest of the app stays ignorant of the wire format.
defmodule MyApp.WhatsApp do
@moduledoc "Send WhatsApp messages from our own number via the Blueticks /v1 API."
@base "https://api.blueticks.co/v1"
# Enqueue a durable, retryable send. Call this from a controller or LiveView.
def enqueue(recipient, text, idem_key) do
%{to: recipient, text: text, idem_key: idem_key}
|> MyApp.Workers.WhatsAppSend.new()
|> Oban.insert()
end
# The raw send the worker calls. Returns {:ok, data} | {:error, reason}.
def deliver(recipient, text, opts \\ []) do
chat_id = URI.encode(recipient, &URI.char_unreserved?/1)
headers = idem_header(opts[:idempotency_key])
with {:ok, %Req.Response{status: s, body: %{"success" => true, "data" => data}}}
when s in 200..299 <-
Req.post("#{@base}/scheduled-messages/#{chat_id}",
auth: {:bearer, api_key()},
json: %{type: "text", text: text},
headers: headers
) do
{:ok, data}
else
{:ok, %Req.Response{status: s, body: b}} -> {:error, {:http_status, s, b}}
{:error, ex} -> {:error, {:transport, ex}}
end
end
defp api_key, do: System.fetch_env!("BLUETICKS_API_KEY")
defp idem_header(nil), do: []
defp idem_header(key), do: [{"idempotency-key", key}]
end
Now a controller writes MyApp.WhatsApp.enqueue(user.phone, "Welcome aboard", "welcome-#{user.id}") and nothing else. The key never leaks into a template, the escaping is applied once, and swapping the transport later touches one file.
How do you track delivery status from Elixir?
Read the status field back and map the string to an atom with a catch-all for forward compatibility. The lifecycle is pending (accepted, waiting), then confirmed (WhatsApp accepted it, waMessageKey is now set), received (one grey tick became two), read (blue ticks), played (a voice note was played), or failed. You can read a single message back by its 24-hex id, or receive the transitions as pushes, which the delivery-status webhooks guide covers in full.
def status_atom(status) do
case status do
"pending" -> :pending
"confirmed" -> :confirmed
"received" -> :received
"read" -> :read
"played" -> :played
"failed" -> :failed
other -> {:unknown, other} # a new server value never crashes your code
end
end
The Elixir habit worth keeping is that catch-all clause. If Blueticks adds a status next year, a strict match would raise CaseClauseError in production; {:unknown, other} degrades gracefully and lets you log the surprise instead of crashing the worker. For recurring cadences and deeper timezone handling, the scheduling-in-production guide covers the mechanism, which is the same regardless of language.
One more Elixir-specific sendAt trap, since it is the most common scheduling bug. Build the timestamp from a DateTime in UTC, not a NaiveDateTime: DateTime.utc_now() |> DateTime.add(2, :hour) |> DateTime.truncate(:second) |> DateTime.to_iso8601() produces 2026-10-02T14:30:00Z, with the Z offset the server requires. A NaiveDateTime serialises with no offset at all, so the API rejects it with a 400. And if you compute sendAt, let the job sit in the queue for nine seconds, and dispatch lands inside the 10-second floor, you get 400 sendAt must be at least 10 seconds in the future. Compute it at send time, or leave a minute of headroom.
How do you stay compliant and reduce ban risk from your own number?
Treat consent as the gate, not the API. You are messaging from your own number under WhatsApp's Business Policy and its rules on automated and bulk messaging, and those rules apply whatever code is behind the send. There is no guaranteed no-ban, and anyone who promises one is selling you something.
The failure mode specific to the BEAM is how easy it is to spawn work. Enum.map(contacts, &Task.async(fn -> send(&1) end)) fires thousands of concurrent processes with a single line, and a wide unsolicited burst like that is the single pattern most likely to get a number flagged. Cap concurrency with max_concurrency as above, spread a large batch over hours with Oban's scheduled_at, and message only people who asked to hear from you. As one Phoenix operator running nightly shipping notifications put it, "the stable Idempotency-Key was the difference between one 'your order shipped' and three at 2am after a deploy replayed the Oban queue."
When should you use the own-number API versus a chatbot or the Cloud API?
Use the own-number path for transactional volume, real two-way conversations, and a number your customers already have saved. Reach for Meta's Cloud API and a Business Solution Provider when you need approved marketing templates at broadcast scale, an Official Business Account badge, or a multi-agent shared inbox. The trade runs both ways.

The Elixir-specific reason a hosted REST engine fits is maintenance. The maintained WhatsApp Web clients are Node projects, not Elixir ones, so running one yourself means supervising a second Node runtime and a browser profile next to your BEAM release forever: a session store, a reconnect supervisor, a pager rotation for something that is not your product. A hosted /v1 endpoint removes that whole tier, the argument the no-Meta-verification guide makes in full.
A two-way whatsapp api phoenix bot build is the same POST plus a webhook receiver to read inbound messages in a Phoenix endpoint, so the send half here does not change. The same first send exists in Go, Rust, Swift, and Kotlin, and the bot-shaped variants live in the Python and Node.js guides.
FAQ
Do I need a WhatsApp SDK or vendor library for Elixir?
No. There is no official WhatsApp SDK for Elixir and you do not need one. The own-number API is plain REST, so Req (which uses Jason under the hood) makes the whole call: Req.post(url, auth: {:bearer, key}, json: %{type: "text", text: "hi"}). Add {:req, "~> 0.5"} to mix.exs and that is the only dependency a first send needs.
Why does waMessageKey come back null, and how do I handle it in Elixir?
On a scheduled send the engine has not dispatched the message yet, so waMessageKey is null, which Jason decodes to nil. It only fills in later as a map with fromMe, remote, id, _serialized, and participant. Pattern-match the two cases: %{"waMessageKey" => nil} for queued and %{"waMessageKey" => %{"_serialized" => s}} for dispatched. Use the 24-hex id as your handle meanwhile.
How do I stop an Oban retry from sending the same WhatsApp message twice?
Send an Idempotency-Key header derived once from the business object ("order-4471-shipped") so every retry reuses it, and put that key in the job args. A replay returns the original response and the same 24-hex id instead of sending again. The key is capped at 64 characters and scoped per workspace. Add Oban's unique: [keys: [:idem_key]] option as a second guard against a duplicate enqueue, and never generate a fresh UUID per attempt.
How do I schedule a message for later from Elixir without a timezone bug?
Pass a camelCase sendAt holding an RFC 3339 timestamp with an offset, built from a UTC DateTime: DateTime.utc_now() |> DateTime.add(1, :day) |> DateTime.truncate(:second) |> DateTime.to_iso8601() gives you the trailing Z. A NaiveDateTime has no offset and is rejected with a 400. The accepted window is roughly 10 seconds to 365 days ahead. Omit sendAt entirely to send immediately.
Do I need the Meta Cloud API to send WhatsApp from a Phoenix app?
No. The own-number path sends from a number you already control over WhatsApp Web or a hosted gateway, so there is no Meta Business verification, no message templates, and no per-message fees. Reach for the Cloud API and a Business Solution Provider only when you need approved marketing templates at broadcast scale or an Official Business Account badge.
Will sending from Elixir get my WhatsApp number banned?
Nobody can guarantee it will not. You are messaging from your own number under WhatsApp's Business Policy and its rules on automated and bulk messaging, and a wide unsupervised Task fan-out firing unsolicited sends is the pattern most likely to get a number flagged. Message people who asked to hear from you, cap concurrency with max_concurrency, spread batches over hours with Oban, and stop when someone asks. Consent is yours to collect.



