Amarula (amarula v0.4.1)

View Source

Amarula — a WhatsApp Web client for Elixir.

Connect to WhatsApp the way the web/desktop app does: pair once by scanning a QR code with your phone, then send and receive messages from Elixir. Every call takes a conn handle, so you can run many accounts at once — there is no global connection.

Quick start

Pair a device (first run), then send a message:

# 1. Start a connection. Events (the QR code, incoming messages) are sent
#    to `parent_pid` — here, the current process.
{:ok, conn} =
  Amarula.new(%{profile: :me})
  |> Amarula.connect(parent_pid: self())

# 2. On the first run you get a QR code to scan with your phone:
#    WhatsApp → Settings → Linked Devices → Link a device.
receive do
  {:amarula, :connection_update, %{qr: qr}} when is_binary(qr) ->
    IO.puts(qr)   # render this string as a QR code
end

# 3. Once linked you get an :open update — now you can send.
receive do
  {:amarula, :connection_update, %{connection: :open}} -> :ready
end

Amarula.send_text(conn, "5511999999999@s.whatsapp.net", "hello from Elixir!")

:profile names this account's stored credentials, so the next run reconnects without a new QR. Amarula.new/1 fills in all protocol defaults; you usually pass only :profile. For a ready-made supervised wrapper, see Amarula.Examples.Connection.

Testing your bot

To test your message-handling logic without a real WhatsApp connection, use Amarula.Testing — it starts an offline connection and lets you deliver synthetic inbound messages that flow through the real receive pipeline.

The QR code

The qr in a :connection_update is a plain string — you turn it into a scannable image, with whatever you like (a terminal renderer, a qr_code PNG, an HTML <img>, …). There is no built-in renderer and no login-phase plugin hook: rendering is entirely the consumer's, via this event. (The handshake/pairing crypto is deliberately closed to plugins — the send/receive pipelines run only after a message is decrypted.)

The string is four comma-separated fields:

"<ref>,<noiseKeyB64>,<identityKeyB64>,<advSecretKeyB64>"
  • ref — a server-issued pairing reference (from the <pair-device> IQ); it rotates every ~20s, so each rotation emits a fresh :connection_update with a new qr — re-render on each.
  • noiseKeyB64 — our Noise static public key (base64).
  • identityKeyB64 — our signed identity public key (base64).
  • advSecretKeyB64 — the companion adv secret key (base64).

The phone reads our public keys + ref from the image to link the device. Render it as-is — don't reformat the string. Example with qr_code (note its API is Result-tuple based — create/1 returns {:ok, qr} and render/2 takes that tuple, so you pipe straight through):

{:amarula, :connection_update, %{qr: qr}} when is_binary(qr) ->
  qr |> QRCode.create() |> QRCode.render(:png) |> QRCode.save("qr.png")

Addressing

A send target is a jid string — "<number>@s.whatsapp.net" for a person, "<id>@g.us" for a group — or an Amarula.Address (use Amarula.Address.pn/1 to build one from a bare number).

Sending

All sends return {:ok, msg_id} or {:error, reason}:

Amarula.send_text(conn, jid, "hello")
Amarula.send_media(conn, jid, :image, image_bytes, caption: "hi")
Amarula.send_reaction(conn, message_key, "👍")   # "" removes the reaction
Amarula.send_edit(conn, message_key, "fixed typo")
Amarula.send_revoke(conn, message_key)            # delete for everyone

A message_key is the key field of a message you received (see below) — that's how you point a reaction/edit/delete at a specific message.

Receiving

Incoming events arrive at parent_pid as {:amarula, type, data} tuples (the full list is in event/0). The main one is :messages_upsert, whose data carries [%Amarula.Msg{}] — the consumer-friendly message view (type + content), never the raw protobuf. Match on msg.type; for media, fetch the bytes lazily with download_media/1.

What Amarula does NOT store

Amarula keeps only what the protocol needs (credentials, Signal sessions, device/LID mappings). It is not a message archive and keeps no chat or DM list: there is no "list my conversations" call, and incoming messages are delivered once via :messages_upsert and then forgotten. If your app needs an inbox, a contact list, or scrollback, persist it yourself from the events — Amarula won't do it for you.

History sync (:history_sync events, and fetch_history/4 to pull more on demand) delivers WhatsApp's own history to you the same way — as events to store, not a queryable archive Amarula maintains. resolve_quoted/2 is the one read-back helper, and only for a reply's quoted message you still hold.

Summary

Types

A connection handle: the pid from connect/2, a registered name, or the :via tuple from via/1 (resolve a profile to a restart-safe handle with whereis/1).

Events delivered to parent_pid as {:amarula, type, data}

A send target: an Amarula.Address or a jid string ("<n>@s.whatsapp.net" / "<id>@g.us").

A media kind handled by send_media/5.

A reference to a specific existing message (for reactions / edits / deletes / poll votes). Either

A connection's profile name (its identity + storage scope).

Result of a send: the assigned message id, or an error.

Functions

Start a built Amarula.Conn and begin connecting. Returns the running conn handle (a pid). Pair with new/1

Current connection state (e.g. :disconnected, :connecting, :connected).

Close the connection's websocket without taking the supervision tree down. Pair with reconnect/1 to bring it back up; use stop/1 to release the profile entirely. Returns :ok | {:error, reason}.

Download + decrypt an incoming media file. Inbound messages carry only media metadata (directPath/mediaKey), not the bytes — call this to fetch them lazily, passing a %Amarula.Msg{type: :media} (or its content, an %Amarula.Content.Media{}). Returns {:ok, bytes} or {:error, reason} (:bad_mac on a failed integrity check, :invalid_media on a malformed descriptor).

Ask the phone for older history of a chat (a PEER_DATA_OPERATION on-demand request). oldest identifies the oldest message you already have — pass the %Amarula.Msg{} you received (most accurate) or a {jid, msg_id} tuple. oldest_ts is that message's millisecond timestamp and count how many older messages to request. The history arrives asynchronously later via the normal :history_sync event. Returns {:ok, request_msg_id} or {:error, :not_authenticated}.

Keep a message in a disappearing chat (exempt it from auto-delete). ref is a %Amarula.Msg{} or {jid, msg_id}.

List every profile that has stored credentials in storage.

Like list_profiles/1, but each entry carries the logged-in identity read from that profile's stored creds — for building a friendlier account picker

Send a read receipt for message_ids in chat jid (pass participant for a group sender). Marks those messages read on the sender's side.

Build a connection value (Amarula.Conn) from config, without starting it.

This connection's own identity as an Amarula.Address — our phone-number address, carrying our companion device id (creds.me.id).

Whether msg is in our own chat (the "Message Yourself" chat): from_me and addressed to our own account. The check a self-chat command channel needs — drive an agent by messaging yourself.

Pin a message for everyone in the chat. ref is a %Amarula.Msg{} or {jid, msg_id}.

(Re)open the connection's websocket on an already-started connection — the inverse of disconnect/1. Runs the handshake and logs in again with the profile's stored credentials. Returns :ok | {:error, reason}.

Request a link-code (phone-number) pairing code for phone (E.164 digits; any +, spaces, or dashes are stripped).

Resolve the original message a reply quotes.

Send an album (grouped media) to jid. items is a list of {type, data, opts} (same shape as send_media/5), where type is :image or :video. Sends the album parent first, then each item referencing it.

Send a typing indicator to jid: :composing, :recording, or :paused.

Send a contact (display_name + vCard string) to jid.

Send multiple contacts to jid: pairs is [{display_name, vcard}, ...].

Edit a message we sent, replacing its text. ref is a %Amarula.Msg{} or a {jid, msg_id} tuple.

Send an event to jid (a group or 1:1). name is the title.

Send a group-invite message to jid — a tap-to-join card for group_jid using code (from Amarula.Group.invite_code/2).

Send media of type (:image/:video/:audio/:document/:sticker).

Send a poll to jid. Returns {:ok, msg_id, message_secret} — keep the message_secret to tally incoming votes (Amarula.Protocol.Messages.Poll).

Cast a vote on an existing poll. poll is a message_ref for the poll-creation message (a %Amarula.Msg{} or {jid, msg_id} tuple); message_secret is the poll's 32-byte secret (from send_poll/5, or the poll's messageContextInfo); option_names are the chosen options. The vote is encrypted under the secret.

React to a message with emoji (empty string removes the reaction). ref is a %Amarula.Msg{} or a {jid, msg_id} tuple.

Delete a message for everyone (revoke). ref is a %Amarula.Msg{} or a {jid, msg_id} tuple.

Send a 1:1/group text message to jid.

Re-attach the consumer event sink of a live connection to sink without bouncing the websocket — the recovery path when the process that called connect/2 restarts while the connection survives in the registry.

Set your global presence: :available (online) or :unavailable.

Stop a connection entirely — the whole supervision tree — and release its profile so it can be started again (here or, with a cluster registry, on another node).

Subscribe to a contact's presence updates.

Undo a previous keep (let the message disappear again). ref is a %Amarula.Msg{} or {jid, msg_id}.

Unpin a previously pinned message. ref is a %Amarula.Msg{} or {jid, msg_id}.

Set your own member tag (per-group self-label) in group, or clear it with "". The tag is capped at 30 characters — a longer one is rejected with {:error, :member_tag_too_long} (we don't silently truncate). Relayed to the group; other members see it via a %Amarula.Content.MemberTag{} message (label "" = removed). Also available as Amarula.Group.update_member_tag/3.

A :via handle for profile — usable anywhere a conn() is accepted, so calls route to whatever pid currently holds the profile (restart-safe). Assumes the default registry; for a custom one, build it from the Conn via Amarula.ProfileRegistry.via/2.

The live connection pid for profile, or nil. A restart-safe handle: the pid changes if the connection restarts, but the profile resolves to the current one.

As whereis/1, but resolves through conn_or_config's :registry.

Destructively forget this connection's profile: unlink the companion on WhatsApp's side (the phone drops the device), wipe all local storage for it (creds, sessions, keys, mappings), then disconnect. After this the profile must be re-paired to use again.

Types

conn()

@type conn() :: GenServer.server()

A connection handle: the pid from connect/2, a registered name, or the :via tuple from via/1 (resolve a profile to a restart-safe handle with whereis/1).

event()

@type event() ::
  :connection_update
  | :messages_upsert
  | :protocol_update
  | :chats_update
  | :contacts_update
  | :group_update
  | :receipt_update
  | :presence_update
  | :blocklist_update
  | :lid_mapping_update
  | :pairing_code
  | :pairing_success
  | :pairing_failure
  | :call_update
  | :history_sync
  | :error

Events delivered to parent_pid as {:amarula, type, data}:

  • :connection_update%{connection: state, qr: qr | nil, ...} (partial map)

  • :messages_upsert%{from: jid, id: id, messages: [%Amarula.Msg{}]}. Real user messages only — control frames are split off (see :protocol_update).
  • :protocol_update%{from: jid, id: id, messages: [%Amarula.Msg{}]} whose type is :protocol: control frames Amarula doesn't surface as messages (ephemeral/setting changes and other unhandled protocolMessage types). Most consumers can ignore it; it exists so control frames don't pollute :messages_upsert. The important ones (history-sync, app-state) arrive on their own events.
  • :chats_update[%Amarula.Chat{}] (from history/app-state sync)
  • :contacts_update[%Amarula.Contact{}]
  • :group_update%{group: Address, author: Address | nil, action: ..} a group membership/metadata change (participant add/remove/promote/demote, subject, announce, lock — see Amarula.Protocol.Groups.Notification)

  • :receipt_update%{message_ids, from, participant, status, timestamp} a message we sent was delivered/read/played (Amarula.Protocol.Messages.Receipt)
  • :presence_update%{jid: Address, participant: Address, presence, last_seen} a contact/group member's presence (:available/:unavailable) or typing state (:composing/:recording) — Amarula.Protocol.Presence
  • :blocklist_update[%{jid, action}] block/unblock changes
  • :lid_mapping_update[%{lid: Address, pn: Address}] newly-learned LID↔PN mappings (from the send pipeline / group metadata). React to these to map a group member's LID back to a PN without a server query (see Amarula.Contacts.pn_for_lid/2).
  • :pairing_code%{code: code} the 8-char link-code (phone-number) pairing code to display (from request_pairing_code/3)
  • :pairing_success%{jid, lid, platform} (QR) or %{via: :link_code} (phone-number pairing)
  • :pairing_failure%{reason: String.t()} pairing could not be completed (e.g. a malformed pair-success); the connection then errors out
  • :call_update — an inbound call event (Amarula.Protocol.Call.t/0): %{chat, from, id, status, timestamp, offline, video?, group?, group_jid}. status is :offer (ringing), :terminate, :timeout (unanswered), :reject, :accept, or :ringing. Use id to correlate a call's :offer with its later :terminate.
  • :history_sync — a batch of synced history (chats/contacts/messages) delivered asynchronously after connect (Amarula.Protocol.Messages.HistorySync)
  • :error — a connection error term

Credentials are persisted by Amarula itself (scoped to the connection's :profile), so there is no :creds_update to handle — name a profile and the next connect reloads its creds automatically.

jid()

@type jid() :: String.t() | Amarula.Address.t()

A send target: an Amarula.Address or a jid string ("<n>@s.whatsapp.net" / "<id>@g.us").

media_type()

@type media_type() :: :image | :video | :audio | :document | :sticker

A media kind handled by send_media/5.

message_ref()

@type message_ref() :: Amarula.Msg.t() | {jid(), String.t()}

A reference to a specific existing message (for reactions / edits / deletes / poll votes). Either:

  • the %Amarula.Msg{} you received (carries its chat, sender, id), or
  • a {jid, msg_id} tuple — the chat jid plus the message id string.

Both are self-contained. (Quote replies use the :quoted opt on send_text, which takes the %Amarula.Msg{} directly.)

profile()

@type profile() :: atom() | String.t()

A connection's profile name (its identity + storage scope).

send_result()

@type send_result() :: {:ok, msg_id :: String.t()} | {:error, term()}

Result of a send: the assigned message id, or an error.

Functions

connect(conn, opts \\ [])

@spec connect(
  Amarula.Conn.t(),
  keyword()
) :: {:ok, conn()} | {:error, {:already_running, pid()}} | {:error, term()}

Start a built Amarula.Conn and begin connecting. Returns the running conn handle (a pid). Pair with new/1:

{:ok, pid} = Amarula.new(config) |> Amarula.connect()

The returned pid is not restart-safe

A raw pid is returned on purpose — you can Process.monitor/1 it to detect a crash, or Process.alive?/1 it. But if the connection crashes, its supervision tree restarts it under a new pid, and the one returned here then points at a dead process. So for anything you hold across time (a GenServer state field, a long-lived process), store the profile and address the connection with via/1 instead:

conn = Amarula.via(profile)          # always resolves to the current pid
Amarula.send_text(conn, jid, "hi")

via/1/whereis/1 resolve through Amarula.ProfileRegistry, which the connection re-registers on every restart. The raw pid is fine for a quick, short-lived script that won't outlive a crash.

Only one connection per profile may run at a time (within the registry's reach — one per node by default; see Amarula.ProfileRegistry). Connecting a profile that's already live returns {:error, {:already_running, pid}} — use whereis/1 to get the existing one.

opts here are process/runtime wiring, distinct from the config map passed to new/1 (the WhatsApp/protocol settings like :mark_online_on_connect):

  • :parent — the event sink (Amarula.Connection.sink/0): a pid, a registered name, a {:via, …} tuple, or {name, node}. A name survives the consumer's restart (it re-resolves to the current holder); a raw pid does not. Default: the caller's pid. Re-point a live connection's sink with set_parent/2.
  • :parent_pid — legacy alias for :parent (a pid). :parent wins if both given.
  • :name — optional registered name for the connection

connection_state(conn)

@spec connection_state(conn()) :: atom()

Current connection state (e.g. :disconnected, :connecting, :connected).

disconnect(conn)

@spec disconnect(conn()) :: :ok | {:error, term()}

Close the connection's websocket without taking the supervision tree down. Pair with reconnect/1 to bring it back up; use stop/1 to release the profile entirely. Returns :ok | {:error, reason}.

download_media(m)

@spec download_media(Amarula.Msg.t() | Amarula.Content.Media.t()) ::
  {:ok, binary()} | {:error, term()}

Download + decrypt an incoming media file. Inbound messages carry only media metadata (directPath/mediaKey), not the bytes — call this to fetch them lazily, passing a %Amarula.Msg{type: :media} (or its content, an %Amarula.Content.Media{}). Returns {:ok, bytes} or {:error, reason} (:bad_mac on a failed integrity check, :invalid_media on a malformed descriptor).

%Amarula.Msg{type: :media} = msg
{:ok, bytes} = Amarula.download_media(msg)

No live connection required

This fetches from WhatsApp's CDN and decrypts with keys carried in the media struct — it does not use the socket or need an open conn. So you can hand a %Amarula.Msg{} off to a Task (or a job queue) and download it later, off the connection process.

fetch_history(conn, oldest, oldest_ts, count)

@spec fetch_history(conn(), message_ref(), integer(), non_neg_integer()) ::
  send_result()

Ask the phone for older history of a chat (a PEER_DATA_OPERATION on-demand request). oldest identifies the oldest message you already have — pass the %Amarula.Msg{} you received (most accurate) or a {jid, msg_id} tuple. oldest_ts is that message's millisecond timestamp and count how many older messages to request. The history arrives asynchronously later via the normal :history_sync event. Returns {:ok, request_msg_id} or {:error, :not_authenticated}.

A {jid, msg_id} tuple can't know from_me/participant, so prefer the %Amarula.Msg{} for a message you sent or a group message.

keep_message(conn, ref)

@spec keep_message(conn(), message_ref()) :: send_result()

Keep a message in a disappearing chat (exempt it from auto-delete). ref is a %Amarula.Msg{} or {jid, msg_id}.

list_profiles(scope)

@spec list_profiles(
  Amarula.Storage.Scope.t()
  | Amarula.Conn.t()
  | {module(), keyword()}
  | keyword()
) ::
  {:ok, [Amarula.Storage.profile()]} | {:error, term()}

List every profile that has stored credentials in storage.

Takes a storage source rather than a live connection: a Amarula.Storage.Scope.t/0, a built %Amarula.Conn{} (use its scope), or a {adapter, opts} / bare-opts storage spec (the same value new/1 accepts as :storage). Returns the profile names that have a :creds entry — what you'd pass as :profile to reconnect.

Amarula.list_profiles(root: "./amarula_data")
#=> {:ok, [:primary, "work"]}

{:error, :not_supported} if the storage adapter can't enumerate profiles.

list_profiles_with_metadata(scope)

@spec list_profiles_with_metadata(
  Amarula.Storage.Scope.t()
  | Amarula.Conn.t()
  | {module(), keyword()}
  | keyword()
) :: {:ok, [Amarula.Storage.profile_info()]} | {:error, term()}

Like list_profiles/1, but each entry carries the logged-in identity read from that profile's stored creds — for building a friendlier account picker:

Amarula.list_profiles_with_metadata(root: "./amarula_data")
#=> {:ok, [%{profile: :primary, jid: "5511...@s.whatsapp.net",
#           lid: "12345@lid", name: "Alice"}]}

Costs one extra storage read per profile. name/jid/lid are nil for a profile that hasn't finished pairing. Accepts the same storage sources as list_profiles/1.

mark_read(conn, jid, message_ids, participant \\ nil)

@spec mark_read(conn(), jid(), [String.t(), ...], jid() | nil) ::
  :ok | {:error, :not_connected}

Send a read receipt for message_ids in chat jid (pass participant for a group sender). Marks those messages read on the sender's side.

new(config)

@spec new(map()) :: Amarula.Conn.t()

Build a connection value (Amarula.Conn) from config, without starting it.

This is the start of the Req-style builder: attach plugins, then connect/2.

Amarula.new(%{profile: :primary})
|> MyPlugin.attach(opts)
|> Amarula.connect()

Connection/protocol defaults are filled in (see Amarula.Config), so config need only carry :profile (+ :auth and any overrides).

Commonly-used options

config is a map; only :profile is required. The options you'll reach for most (full list + defaults in Amarula.Config):

  • :profilerequired. Names this connection's stored credentials, so it reconnects without re-pairing. Any term (e.g. :primary, "acct-42").
  • :mark_online_on_connect (default true) — send presence-available on connect. Set false to stay offline to others; the primary phone then keeps receiving push notifications (live messages are queued offline rather than pushed to this session).
  • :browser (default ["Mac OS", "Chrome", "14.4.1"]) — the [os, client, version] triple shown in the user's Linked devices. A "Android" client element opts into Android registration (can receive view-once media; see Amarula.Config).
  • :sync_full_history (default true) — request full history on link.
  • :auth — explicit creds (advanced; normally Amarula loads/persists these for you from :profile).
  • :offline (default false) — sandbox mode (below).

Every per-connection setting can be overridden here and wins over the default — see the full table in Amarula.Config.

Offline (sandbox) mode

offline: true runs the connection with no socket: it never reaches WhatsApp, and every send_* short-circuits to {:ok, msg_id} without encrypting or emitting a frame. Combined with Amarula.Testing.deliver_* (which feeds synthetic inbound messages), this lets you run your bot end to end — receive a message, reply to it — with no real-world effect. See Amarula.Testing.

own_address(conn)

@spec own_address(conn()) :: Amarula.Address.t()

This connection's own identity as an Amarula.Address — our phone-number address, carrying our companion device id (creds.me.id).

Always returns an Address: before login (no identity yet) it returns Amarula.Address.empty/0, so you never have to nil-check. own_address(conn).device is nil for the primary device / phone, or the linked-device number (e.g. 29) for a companion like this app.

Use it to detect messages this app/device itself sent — e.g. to ignore the agent's own self-chat sends and avoid a feedback loop. This does a call into the connection, and our own device is constant after login, so read it once and reuse the device:

own_device = Amarula.own_address(conn).device

# then, per received message:
if msg.from_me and msg.from.device == own_device do
  :ignore   # this device sent it
end

own_chat?(conn, msg)

@spec own_chat?(conn(), Amarula.Msg.t()) :: boolean()

Whether msg is in our own chat (the "Message Yourself" chat): from_me and addressed to our own account. The check a self-chat command channel needs — drive an agent by messaging yourself.

Handles the LID/PN duality: the self chat may be addressed by our PN or our LID, so it matches msg.to against both of our own identities (see Amarula.Connection.own_chat?/2).

On a single connection there's no feedback loop — a reply this connection sends to the self chat is not delivered back to it (the sender's own device is excluded from delivery), so you don't need to filter your own sends. Dedupe by msg_id only when running two connections on the same account.

pin_message(conn, ref)

@spec pin_message(conn(), message_ref()) :: send_result()

Pin a message for everyone in the chat. ref is a %Amarula.Msg{} or {jid, msg_id}.

reconnect(conn)

@spec reconnect(conn()) :: :ok | {:error, term()}

(Re)open the connection's websocket on an already-started connection — the inverse of disconnect/1. Runs the handshake and logs in again with the profile's stored credentials. Returns :ok | {:error, reason}.

request_pairing_code(conn, phone, opts \\ [])

@spec request_pairing_code(conn(), String.t(), keyword()) ::
  {:ok, String.t()} | {:error, term()}

Request a link-code (phone-number) pairing code for phone (E.164 digits; any +, spaces, or dashes are stripped).

Call this during the QR window while unregistered — on the first :connection_update carrying a qr. Returns {:ok, code} with an 8-char code the user types into WhatsApp → Linked Devices → "Link with phone number". Amarula finishes the handshake internally; the usual 515 restart then logs in (watch for :pairing_success then connection: :open). The same code is also delivered as a :pairing_code event.

Options

  • :custom_code (String.t/0) - a fixed 8-char code to use instead of a random one.

resolve_quoted(conn, msg)

@spec resolve_quoted(conn(), Amarula.Msg.t()) ::
  {:ok, Amarula.Msg.t()} | {:requested, String.t()} | {:error, term()}

Resolve the original message a reply quotes.

  1. If the reply carries the inline copy WhatsApp ships (msg.quoted.message), return it immediately — {:ok, %Amarula.Msg{}}.
  2. Otherwise ask the server to re-deliver the original — {:requested, id}; it re-arrives async via :messages_upsert.

{:error, :not_a_reply} if msg doesn't quote anything.

Amarula does not keep an inbound-message store — delivery ends at :messages_upsert (the message, its mentions, and the inline quote are all there). If you want to resolve a quote from your own history, look it up in whatever store you keep and skip this; this function only handles the inline copy and the server round-trip.

send_album(conn, jid, items)

@spec send_album(conn(), jid(), [{:image | :video, binary(), keyword()}]) ::
  {:ok, String.t()} | {:error, term()}

Send an album (grouped media) to jid. items is a list of {type, data, opts} (same shape as send_media/5), where type is :image or :video. Sends the album parent first, then each item referencing it.

Returns {:ok, parent_msg_id} once the parent and all items are sent, or {:error, {:album_item, index, reason}} if an item fails (the parent and earlier items have already been sent).

WhatsApp expects ≥2 items, all images/videos.

send_chatstate(conn, jid, type)

@spec send_chatstate(conn(), jid(), :composing | :recording | :paused) ::
  :ok | {:error, :not_connected}

Send a typing indicator to jid: :composing, :recording, or :paused.

send_contact(conn, jid, display_name, vcard)

@spec send_contact(conn(), jid(), String.t(), String.t()) :: send_result()

Send a contact (display_name + vCard string) to jid.

send_contacts(conn, jid, display_name, pairs)

@spec send_contacts(conn(), jid(), String.t(), [{String.t(), String.t()}, ...]) ::
  send_result()

Send multiple contacts to jid: pairs is [{display_name, vcard}, ...].

send_edit(conn, ref, new_text)

@spec send_edit(conn(), message_ref(), String.t()) :: send_result()

Edit a message we sent, replacing its text. ref is a %Amarula.Msg{} or a {jid, msg_id} tuple.

send_event(conn, jid, name, opts \\ [])

@spec send_event(conn(), jid(), String.t(), keyword()) :: send_result()

Send an event to jid (a group or 1:1). name is the title.

Options

  • :description (String.t/0) - event description.

  • :location (term/0) - {lat, lng} or [lat:, lng:, name:, address:].

  • :join_link (String.t/0) - a call/meeting link to join.

  • :start_time (integer/0) - event start (unix seconds).

  • :end_time (integer/0) - event end (unix seconds).

  • :extra_guests_allowed (boolean/0) - whether guests may invite others.

Responding to an event (RSVP) is not yet supported — the response is an encrypted EncEventResponseMessage, a separate crypto seam (like poll votes).

send_group_invite(conn, jid, group_jid, code, opts \\ [])

@spec send_group_invite(conn(), jid(), String.t(), String.t(), keyword()) ::
  send_result()

Send a group-invite message to jid — a tap-to-join card for group_jid using code (from Amarula.Group.invite_code/2).

Options

send_location(conn, jid, lat, lng, opts \\ [])

@spec send_location(conn(), jid(), float(), float(), keyword()) :: send_result()

Send a location to jid.

Options

  • :name (String.t/0) - place name shown on the card.

  • :address (String.t/0) - street address shown under the name.

  • :url (String.t/0) - link attached to the location.

  • :is_live (boolean/0) - send as a live (updating) location.

send_media(conn, jid, type, data, opts \\ [])

@spec send_media(conn(), jid(), media_type(), binary(), keyword()) :: send_result()

Send media of type (:image/:video/:audio/:document/:sticker).

data is the raw file bytes — not a path, not base64. Read the file yourself first:

bytes = File.read!("photo.jpg")
Amarula.send_media(conn, jid, :image, bytes, caption: "hi")

Amarula encrypts and uploads the bytes for you.

Audio needs :seconds

Amarula does no media processing — it won't compute an audio clip's duration for you. Pass :seconds (the clip length) for :audio. Without it, clips longer than ~10s may fail to play on iPhone recipients (WhatsApp rejects the playback and asks the sender to resend — Baileys #2646).

Options

  • :mimetype (String.t/0) - content type; auto-detected per type if omitted.

  • :caption (String.t/0) - text shown under the media.

  • :width (non_neg_integer/0) - image/video width in px.

  • :height (non_neg_integer/0) - image/video height in px.

  • :seconds (non_neg_integer/0) - duration; required for :audio (see the warning above).

  • :ptt (boolean/0) - send :audio as a voice note.

  • :waveform (String.t/0) - amplitude preview (raw bytes) for a voice note.

  • :file_name (String.t/0) - document file name.

  • :title (String.t/0) - document title.

  • :view_once (boolean/0) - send as view-once (the recipient can open it once).

  • :ptv (boolean/0) - for :video, send as a round video note (PTV).

  • :quoted - reply to a message: an %Amarula.Msg{} (full quote), or a {msg_id, participant} tuple (lightweight — threads by id, no inline preview if the recipient lacks the original).

  • :mentions - jids / %Amarula.Address{} to tag (@mention).

send_poll(conn, jid, name, options, opts \\ [])

@spec send_poll(conn(), jid(), String.t(), [String.t(), ...], keyword()) ::
  {:ok, String.t(), binary()} | {:error, term()}

Send a poll to jid. Returns {:ok, msg_id, message_secret} — keep the message_secret to tally incoming votes (Amarula.Protocol.Messages.Poll).

Options

  • :selectable (non_neg_integer/0) - max options a voter may pick. The default value is 1.

  • :announcement (boolean/0) - send as an announcement-group poll.

  • :message_secret (String.t/0) - 32-byte secret to encrypt votes under (generated if omitted).

send_poll_vote(conn, poll, message_secret, option_names, opts \\ [])

@spec send_poll_vote(conn(), message_ref(), binary(), [String.t()], keyword()) ::
  send_result()

Cast a vote on an existing poll. poll is a message_ref for the poll-creation message (a %Amarula.Msg{} or {jid, msg_id} tuple); message_secret is the poll's 32-byte secret (from send_poll/5, or the poll's messageContextInfo); option_names are the chosen options. The vote is encrypted under the secret.

The poll's creator is taken from the ref (a %Amarula.Msg{}'s sender, or the {jid, _} chat for a 1:1 poll).

Options

  • :creator - poll creator's jid / %Amarula.Address{} (required for a group poll given as a {jid, msg_id} tuple).

send_reaction(conn, ref, emoji)

@spec send_reaction(conn(), message_ref(), String.t()) :: send_result()

React to a message with emoji (empty string removes the reaction). ref is a %Amarula.Msg{} or a {jid, msg_id} tuple.

send_revoke(conn, ref)

@spec send_revoke(conn(), message_ref()) :: send_result()

Delete a message for everyone (revoke). ref is a %Amarula.Msg{} or a {jid, msg_id} tuple.

send_text(conn, jid, text, opts \\ [])

@spec send_text(conn(), jid(), String.t(), keyword()) :: send_result()

Send a 1:1/group text message to jid.

Options

  • :quoted - reply to a message: an %Amarula.Msg{} (full quote), or a {msg_id, participant} tuple (lightweight — threads by id, no inline preview if the recipient lacks the original).

  • :mentions - jids / %Amarula.Address{} to tag (@mention).

set_parent(conn, sink)

@spec set_parent(conn(), Amarula.Connection.sink()) :: :ok

Re-attach the consumer event sink of a live connection to sink without bouncing the websocket — the recovery path when the process that called connect/2 restarts while the connection survives in the registry.

conn is any connection handle (a pid, or via/1 of a profile); sink is a Amarula.Connection.sink/0. Pass a registered name (not a raw pid) for a sink that also survives the connection's own restart. Returns :ok.

Amarula.set_parent(Amarula.via(:primary), self())

set_presence(conn, type)

@spec set_presence(conn(), :available | :unavailable) :: :ok | {:error, term()}

Set your global presence: :available (online) or :unavailable.

stop(pid)

@spec stop(conn() | profile()) :: :ok | {:error, :not_found}

Stop a connection entirely — the whole supervision tree — and release its profile so it can be started again (here or, with a cluster registry, on another node).

Unlike disconnect/1 (which only closes the websocket; the supervised tree stays up and may reconnect), stop/1 takes the tree down and frees the profile slot. Accepts a connection pid or a profile (resolved via the default registry). Returns :ok | {:error, :not_found}.

subscribe_presence(conn, jid)

@spec subscribe_presence(conn(), jid()) :: :ok | {:error, :not_connected}

Subscribe to a contact's presence updates.

unkeep_message(conn, ref)

@spec unkeep_message(conn(), message_ref()) :: send_result()

Undo a previous keep (let the message disappear again). ref is a %Amarula.Msg{} or {jid, msg_id}.

unpin_message(conn, ref)

@spec unpin_message(conn(), message_ref()) :: send_result()

Unpin a previously pinned message. ref is a %Amarula.Msg{} or {jid, msg_id}.

update_member_tag(conn, group, label)

@spec update_member_tag(conn(), jid(), String.t()) ::
  send_result() | {:error, :member_tag_too_long}

Set your own member tag (per-group self-label) in group, or clear it with "". The tag is capped at 30 characters — a longer one is rejected with {:error, :member_tag_too_long} (we don't silently truncate). Relayed to the group; other members see it via a %Amarula.Content.MemberTag{} message (label "" = removed). Also available as Amarula.Group.update_member_tag/3.

via(profile)

@spec via(profile()) :: {:via, module(), {atom(), profile()}}

A :via handle for profile — usable anywhere a conn() is accepted, so calls route to whatever pid currently holds the profile (restart-safe). Assumes the default registry; for a custom one, build it from the Conn via Amarula.ProfileRegistry.via/2.

whereis(profile)

@spec whereis(profile()) :: pid() | nil

The live connection pid for profile, or nil. A restart-safe handle: the pid changes if the connection restarts, but the profile resolves to the current one.

Assumes the default Amarula.ProfileRegistry. With a custom :registry config, pass the Conn (or config) as the first arg: whereis(conn, profile).

whereis(conn_or_config, profile)

@spec whereis(Amarula.Conn.t() | map(), profile()) :: pid() | nil

As whereis/1, but resolves through conn_or_config's :registry.

wipe_credentials(conn)

@spec wipe_credentials(conn()) :: :ok | {:error, term()}

Destructively forget this connection's profile: unlink the companion on WhatsApp's side (the phone drops the device), wipe all local storage for it (creds, sessions, keys, mappings), then disconnect. After this the profile must be re-paired to use again.

For a non-destructive teardown that keeps the credentials, use disconnect/1 (websocket only) or stop/1 (the whole tree, freeing the profile slot).