Stable public client facade for ExWapp.
Public application code should create a %ExWapp.Client{} with new/1 and
pass the returned client to the functions in this module. Each mutating
operation returns an updated client, so applications normally keep it in
their own GenServer, GenStateMachine, or other state owner.
Quick Start
client =
ExWapp.new(
session_id: "account_1",
store: {ExWapp.Store.Ets, path: "/var/lib/my_app/account_1.etf"},
transport: ExWapp.Client.Transport.Session,
events: MyApp.WhatsAppEvents
)
{:ok, client} = ExWapp.connect(client)
{:ok, client, {:code, qr}} = ExWapp.pair(client)
{:ok, client, message_id} =
ExWapp.send_message(client, to: "393XXXXXXXXX@s.whatsapp.net", text: "hello")ExWapp.Session and PID-accepting compatibility clauses expose the internal
built-in runtime. They may return the same chat and message values, but are
not the supported public API and can change as the runtime evolves.
See the README for installation, JSON configuration, storage, events, and the complete public API overview.
Summary
Functions
Returns the unbounded local message stream for a chat.
Archives a chat (server-synced via app state).
Initiates connection to WhatsApp servers.
Controls a session worker using either pid (direct cast) or session_id (PubSub command).
Creates or updates a contact (server-synced via app state).
Deletes a chat (server-synced via app state).
Deletes a contact (server-synced via app state).
Deletes selected local messages after they have been persisted elsewhere.
Returns a compact diagnostics map for a high-level client.
Gracefully disconnects from WhatsApp servers.
Downloads and decrypts image, audio, or document media.
Gets one stored call log entry by call ID.
Gets a specific chat by JID.
Returns local metadata for a specific chat.
Gets a specific contact by JID.
Gets locally retained messages for a chat, newest first.
Returns the session_id for a session process.
Returns send health counters and guard state for a running session.
Returns the last structured error recorded on a high-level client.
Lists the locally stored call log, newest first.
Lists all chats for a session.
Lists all contacts.
Lists only group chats for a session.
Marks a chat as read (server-synced via app state).
Marks all messages in a chat as read.
Builds a lazy stream of incoming messages.
Mutes a chat (server-synced via app state).
Creates a high-level, runtime-neutral client.
Starts pairing for a high-level client.
Pins a chat to the top (server-synced via app state).
Returns current policy status (safety breaker, quota, rate limiter snapshot).
Returns a stream of QR code and pairing events.
Handles an inbound payload through a high-level client transport.
Requests phone-number pairing instead of displaying a QR code.
Requests a fresh direct path for locally stored media by message ID.
Returns the effective runtime configuration for a running session.
Sends audio through a high-level client.
Sends one vCard contact through a high-level client or session process.
Sends a document through a high-level client or a session process.
Sends WhatsApp's experimental calendar event message.
Sends an image through a high-level client.
Sends a GPS location through a high-level client or a session process.
Sends a text message to a contact or group.
Sends a text message and waits for the server's verdict on it.
Sends a read receipt to mark messages as read.
Sends a text message to a WhatsApp contact or group.
Sends a typing indicator to a chat.
Starts a WhatsApp session process.
Returns the current session status.
Returns session statistics for monitoring.
Returns the status of a high-level client.
Builds a deferred stream over the locally stored call log, oldest first.
Builds a lazy stream over locally retained messages.
Subscribe the calling process to a session's events via PubSub.
Subscribe to events from ALL sessions via PubSub.
Fetches and applies the contacts collection from app state (critical_unblock_low).
Unarchives a chat (server-synced via app state).
Unmutes a chat (server-synced via app state).
Unpins a chat (server-synced via app state).
Unsubscribe the calling process from a session's events.
Unsubscribe from all global topics (messages + lifecycle).
Functions
@spec all_messages(ExWapp.Client.t() | GenServer.server(), String.t(), keyword()) :: {:ok, Enumerable.t()} | {:error, ExWapp.Error.t(), ExWapp.Client.t()} | Enumerable.t()
Returns the unbounded local message stream for a chat.
This is the explicit convenience form of stream_messages/3.
@spec archive_chat(GenServer.server(), String.t()) :: :ok | {:error, term()}
Archives a chat (server-synced via app state).
Falls back to local-only archive if not connected.
@spec connect(ExWapp.Client.t() | GenServer.server()) :: {:ok, ExWapp.Client.t()} | {:error, ExWapp.Error.t(), ExWapp.Client.t()} | :ok
Initiates connection to WhatsApp servers.
This starts the Noise handshake and pairing flow. The call returns
immediately; use qr_stream/1 to receive pairing events.
Example
{:ok, session} = ExWapp.start_link()
:ok = ExWapp.connect(session)
# Now stream QR codes
ExWapp.qr_stream(session) |> Stream.run()
@spec control_session( pid() | binary(), :pause_sends | :resume_sends | :disconnect | :stop_session ) :: :ok
Controls a session worker using either pid (direct cast) or session_id (PubSub command).
Supported actions:
:pause_sends:resume_sends:disconnect:stop_session
@spec create_contact(ExWapp.Client.t() | GenServer.server(), String.t(), String.t()) :: {:ok, ExWapp.Client.t()} | {:error, ExWapp.Error.t(), ExWapp.Client.t()} | :ok | {:error, term()}
Creates or updates a contact (server-synced via app state).
@spec delete_chat(GenServer.server(), String.t()) :: :ok | {:error, term()}
Deletes a chat (server-synced via app state).
@spec delete_contact(ExWapp.Client.t() | GenServer.server(), String.t()) :: {:ok, ExWapp.Client.t()} | {:error, ExWapp.Error.t(), ExWapp.Client.t()} | :ok | {:error, term()}
Deletes a contact (server-synced via app state).
@spec delete_messages( ExWapp.Client.t() | GenServer.server(), String.t(), [String.t()] ) :: {:ok, ExWapp.Client.t()} | {:error, ExWapp.Error.t(), ExWapp.Client.t()} | :ok | {:error, term()}
Deletes selected local messages after they have been persisted elsewhere.
Delete only IDs that the wrapper has committed successfully. With a client,
the updated client is returned; with a direct session/store the result is
:ok or a store error.
@spec diagnostics(ExWapp.Client.t()) :: map()
Returns a compact diagnostics map for a high-level client.
@spec disconnect(ExWapp.Client.t() | GenServer.server()) :: {:ok, ExWapp.Client.t()} | {:error, ExWapp.Error.t(), ExWapp.Client.t()} | :ok
Gracefully disconnects from WhatsApp servers.
Stops automatic reconnection and closes the connection cleanly.
Call connect/1 to reconnect.
@spec download_media( ExWapp.Client.t() | GenServer.server(), ExWapp.Media.Ref.t(), keyword() ) :: {:ok, ExWapp.Client.t(), ExWapp.Media.Download.t()} | {:error, ExWapp.Error.t(), ExWapp.Client.t()} | {:ok, ExWapp.Media.Download.t()} | {:error, term()}
Downloads and decrypts image, audio, or document media.
@spec get_call(ExWapp.Client.t() | GenServer.server(), String.t()) :: {:ok, ExWapp.Call.t()} | {:error, :not_found} | {:error, ExWapp.Error.t(), ExWapp.Client.t()}
Gets one stored call log entry by call ID.
@spec get_chat(ExWapp.Client.t() | GenServer.server(), String.t()) :: {:ok, ExWapp.Chat.chat()} | {:error, ExWapp.Error.t(), ExWapp.Client.t()} | {:error, :not_found}
Gets a specific chat by JID.
Example
{:ok, chat} = ExWapp.get_chat(session, "1234567890@s.whatsapp.net")
IO.inspect(ExWapp.get_messages(session, chat.jid, limit: 20))
@spec get_chat_info(GenServer.server(), String.t()) :: {:ok, ExWapp.Chat.chat()} | {:error, :not_found}
Returns local metadata for a specific chat.
@spec get_contact(ExWapp.Client.t() | GenServer.server(), String.t()) :: {:ok, ExWapp.Contact.t() | nil} | {:error, ExWapp.Error.t(), ExWapp.Client.t()} | ExWapp.Contact.t() | nil
Gets a specific contact by JID.
@spec get_messages(ExWapp.Client.t() | GenServer.server(), String.t(), keyword()) :: {:ok, [ExWapp.Chat.message()]} | {:error, ExWapp.Error.t(), ExWapp.Client.t()} | [ExWapp.Chat.message()]
Gets locally retained messages for a chat, newest first.
With a high-level client this returns {:ok, messages} (or a structured
client error); with a direct session it returns the message list.
Options
:limit- Maximum messages to return (default: 50), or:all:offset- Number of messages to skip (default: 0)
limit: :all reads every message retained locally for that chat. It does
not trigger an on-demand download of older WhatsApp history.
Examples
{:ok, recent} = ExWapp.get_messages(client, jid, limit: 20)
all_local = ExWapp.get_messages(session, jid, limit: :all)
@spec get_session_id(GenServer.server()) :: term() | nil
Returns the session_id for a session process.
Returns nil when no explicit :session_id option was provided at start.
Example
session_id = ExWapp.get_session_id(session)
@spec health_status(GenServer.server()) :: map()
Returns send health counters and guard state for a running session.
@spec last_error(ExWapp.Client.t()) :: ExWapp.Error.t() | nil
Returns the last structured error recorded on a high-level client.
@spec list_calls( ExWapp.Client.t() | GenServer.server(), keyword() ) :: {:ok, [ExWapp.Call.t()]} | {:error, ExWapp.Error.t(), ExWapp.Client.t()} | [ExWapp.Call.t()]
Lists the locally stored call log, newest first.
The log is fed by live <call> stanzas (who called, ringing/answered/missed,
video or voice) and by the CallLogRecord entries the phone syncs via
app-state, which add the authoritative outcome and duration. Timestamps are
unix seconds; durations are seconds. See ExWapp.Call.list/2 for all
options.
Example
# 20 most recent calls
calls = ExWapp.list_calls(session, limit: 20)
# Missed calls only, since a unix timestamp
missed = ExWapp.list_calls(session, status: :missed, since: 1_754_000_000)
Enum.each(calls, fn call ->
IO.puts("#{call.chat_jid} #{call.status} #{call.duration || 0}s")
end)
@spec list_chats(ExWapp.Client.t() | GenServer.server()) :: {:ok, [ExWapp.Chat.chat()]} | {:error, ExWapp.Error.t(), ExWapp.Client.t()} | [ExWapp.Chat.chat()]
Lists all chats for a session.
Returns chats sorted by last message timestamp (most recent first).
Example
chats = ExWapp.list_chats(session)
Enum.each(chats, fn chat ->
IO.puts("#{chat.name || chat.jid} - #{chat.unread_count} unread")
end)
@spec list_contacts(ExWapp.Client.t() | GenServer.server()) :: {:ok, [ExWapp.Contact.t()]} | {:error, ExWapp.Error.t(), ExWapp.Client.t()} | [ExWapp.Contact.t()]
Lists all contacts.
@spec list_groups(ExWapp.Client.t() | GenServer.server()) :: {:ok, [ExWapp.Chat.chat()]} | {:error, ExWapp.Error.t(), ExWapp.Client.t()} | [ExWapp.Chat.chat()]
Lists only group chats for a session.
Returns chats where JID ends with @g.us, sorted by most recent activity.
@spec mark_chat_as_read(GenServer.server(), String.t()) :: :ok | {:error, term()}
Marks a chat as read (server-synced via app state).
@spec mark_chat_read(GenServer.server(), String.t()) :: :ok
Marks all messages in a chat as read.
Example
ExWapp.mark_chat_read(session, "1234567890@s.whatsapp.net")
@spec message_stream(GenServer.server()) :: Enumerable.t()
Builds a lazy stream of incoming messages.
Each element is a tuple of {jid, message}.
Example
ExWapp.message_stream(session)
|> Stream.each(fn {jid, msg} ->
IO.puts("[#{jid}] #{msg.text}")
end)
|> Stream.run()
@spec mute_chat(GenServer.server(), String.t(), integer()) :: :ok | {:error, term()}
Mutes a chat (server-synced via app state).
mute_end_timestamp is milliseconds epoch, or -1 for forever, or 0/nil to unmute.
@spec new(keyword()) :: ExWapp.Client.t()
Creates a high-level, runtime-neutral client.
@spec pair(ExWapp.Client.t()) :: {:ok, ExWapp.Client.t(), term()} | {:error, ExWapp.Error.t(), ExWapp.Client.t()}
Starts pairing for a high-level client.
@spec pin_chat(GenServer.server(), String.t()) :: :ok | {:error, term()}
Pins a chat to the top (server-synced via app state).
@spec policy_status(GenServer.server()) :: map()
Returns current policy status (safety breaker, quota, rate limiter snapshot).
@spec qr_stream(GenServer.server()) :: Enumerable.t()
Returns a stream of QR code and pairing events.
Subscribe to this stream after calling connect/1 to receive:
{:code, data}- New QR code payload to display (codes expire periodically){:pairing_code, code}- Phone-number pairing code requested throughrequest_pairing_code/3:success- Successfully paired with phone{:error, reason}- Pairing failed
Example
ExWapp.qr_stream(session)
|> Stream.each(fn
{:code, data} ->
# Render QR code (e.g., using qrencode or terminal output)
qr = ExWapp.Pairing.QR.to_terminal(data)
IO.puts(qr)
:success ->
IO.puts("Paired successfully")
{:error, :timeout} ->
IO.puts("QR expired, generating new one...")
end)
|> Stream.run()
@spec receive_message(ExWapp.Client.t(), term(), keyword()) :: {:ok, ExWapp.Client.t(), [ExWapp.Event.t()]} | {:error, ExWapp.Error.t(), ExWapp.Client.t()}
Handles an inbound payload through a high-level client transport.
@spec request_pairing_code(GenServer.server(), String.t(), keyword()) :: {:ok, String.t()} | {:error, term()}
Requests phone-number pairing instead of displaying a QR code.
Invoke it after connect/1 when the transport is ready for pairing. The
returned code is entered in WhatsApp under Link a device with phone
number.
@spec retry_media(GenServer.server(), String.t(), keyword()) :: {:ok, ExWapp.Media.Ref.t()} | {:error, term()}
Requests a fresh direct path for locally stored media by message ID.
@spec runtime_config(GenServer.server()) :: map()
Returns the effective runtime configuration for a running session.
@spec send_audio( ExWapp.Client.t() | GenServer.server(), String.t(), ExWapp.Media.source(), keyword() ) :: {:ok, ExWapp.Client.t(), String.t()} | {:error, ExWapp.Error.t(), ExWapp.Client.t()} | {:ok, String.t()} | {:error, term()}
Sends audio through a high-level client.
@spec send_contact( ExWapp.Client.t() | GenServer.server(), String.t(), String.t(), String.t(), keyword() ) :: {:ok, ExWapp.Client.t(), String.t()} | {:error, ExWapp.Error.t(), ExWapp.Client.t()} | {:ok, String.t()} | {:error, term()}
Sends one vCard contact through a high-level client or session process.
@spec send_document( ExWapp.Client.t() | GenServer.server(), String.t(), ExWapp.Media.source(), keyword() ) :: {:ok, ExWapp.Client.t(), String.t()} | {:error, ExWapp.Error.t(), ExWapp.Client.t()} | {:ok, String.t()} | {:error, term()}
Sends a document through a high-level client or a session process.
@spec send_event( ExWapp.Client.t() | GenServer.server(), String.t(), String.t(), integer() | DateTime.t(), keyword() ) :: {:ok, ExWapp.Client.t(), String.t()} | {:error, ExWapp.Error.t(), ExWapp.Client.t()} | {:ok, String.t()} | {:error, term()}
Sends WhatsApp's experimental calendar event message.
@spec send_image( ExWapp.Client.t() | GenServer.server(), String.t(), ExWapp.Media.source(), keyword() ) :: {:ok, ExWapp.Client.t(), String.t()} | {:error, ExWapp.Error.t(), ExWapp.Client.t()} | {:ok, String.t()} | {:error, term()}
Sends an image through a high-level client.
@spec send_location( ExWapp.Client.t() | GenServer.server(), String.t(), number(), number(), keyword() ) :: {:ok, ExWapp.Client.t(), String.t()} | {:error, ExWapp.Error.t(), ExWapp.Client.t()} | {:ok, String.t()} | {:error, term()}
Sends a GPS location through a high-level client or a session process.
@spec send_message( ExWapp.Client.t(), keyword() ) :: {:ok, ExWapp.Client.t(), String.t()} | {:error, ExWapp.Error.t(), ExWapp.Client.t()}
Sends a text message to a contact or group.
Accepts either a session pid or a session_id string.
When called with a pid, the call is synchronous and returns the message ID.
When called with a session_id string, the send command is dispatched via
PubSub (fire-and-forget) and returns :ok immediately.
Returns
{:ok, message_id}- Message was queued for sending (pid path):ok- Send command dispatched via PubSub (session_id path){:error, reason}- Failed to send
Examples
# With session pid (synchronous, returns message_id)
{:ok, id} = ExWapp.send_message(session, "1234567890@s.whatsapp.net", "Hello!")
# With session_id string (fire-and-forget via PubSub)
:ok = ExWapp.send_message("my_session", "1234567890@s.whatsapp.net", "Hello!")
@spec send_message_await(pid(), String.t(), String.t(), timeout()) :: {:ok, String.t()} | {:error, {:rejected, ExWapp.Error.Ack.t()}} | {:error, :ack_timeout} | {:error, term()}
Sends a text message and waits for the server's verdict on it.
send_message/3 returns once the encrypted stanza reaches the socket, which
is not the same as WhatsApp accepting it: the server answers a message it
accepts with an <ack> and stays silent for one it drops. A caller that
treats the write as success records dropped messages as delivered, so the
three outcomes are separated here:
{:ok, message_id}— the server acknowledged the message{:error, {:rejected, %ExWapp.Error.Ack{}}}— the server refused it, with a classified reason saying whether a resend can succeed{:error, :ack_timeout}— no answer withintimeout; genuinely unknown, and specifically not proof of non-delivery
A rejection blaming the Signal session is repaired before it is reported: the
peer's sessions are dropped and the message is resent under a fresh one, reusing
the same ID, so this returns the verdict on the repaired send. A repair that is
rejected in turn is made once more after a pause, because in practice the same
payload has gone through seconds later. So timeout has to cover several round
trips plus send.session_repair_backoff_ms, not one send — the defaults put
that at roughly ten seconds before a session rejection is reported.
Blocks the calling process. Delivery events are consumed from this process's
mailbox and any that arrive for other messages are discarded on the way out,
so the caller's mailbox is left as it was. A caller that would rather not
block should subscribe to "ex_wapp:delivery:<session_id>" directly and
correlate on the message ID.
@spec send_read_receipt(GenServer.server(), String.t(), [String.t()]) :: :ok | {:error, term()}
Sends a read receipt to mark messages as read.
Example
ExWapp.send_read_receipt(session, jid, ["MSG_ID_1", "MSG_ID_2"])
@spec send_text(GenServer.server(), binary(), iodata()) :: {:ok, String.t()} | {:error, term()}
Sends a text message to a WhatsApp contact or group.
The session must be paired before sending messages. The jid is the
recipient's WhatsApp ID (phone number for individuals, group ID for groups).
Examples
# Send to individual (use full JID format)
ExWapp.send_text(session, "1234567890@s.whatsapp.net", "Hello!")
# Send to group
ExWapp.send_text(session, "123456789-987654321@g.us", "Hello group!")
@spec send_typing(GenServer.server(), String.t(), boolean()) :: :ok | {:error, term()}
Sends a typing indicator to a chat.
Example
# Show typing
ExWapp.send_typing(session, jid, true)
# Stop typing
ExWapp.send_typing(session, jid, false)
@spec start_link(keyword()) :: GenServer.on_start()
Starts a WhatsApp session process.
Options
:store- Storage adapter for session state. SeeExWapp.Store. Defaults toExWapp.Store.Etswith default path.:name- Optional name to register the process.:runtime- Per-session runtime config overrides (same shape asconfig :ex_wapp, :runtime).:client_version/:wa_version- Optional shorthand override forruntime.protocol.client_version. Accepted forms:"2.3000.1034187832",{2, 3000, 1034187832}, or%{primary: 2, secondary: 3000, tertiary: 1034187832}.
Examples
# Anonymous session with default storage
{:ok, session} = ExWapp.start_link()
# Named session with custom storage
{:ok, _} = ExWapp.start_link(
name: MyApp.WhatsApp,
store: {ExWapp.Store.Ets, path: "/tmp/wa.etf"}
)
# For testing
{:ok, session} = ExWapp.start_link(store: ExWapp.Store.Memory)
@spec state(GenServer.server()) :: ExWapp.Session.status()
Returns the current session status.
Possible values: :idle, :connecting, :handshaking, :syncing,
:connected, :disconnected.
Example
iex> ExWapp.state(session)
:connected
@spec stats(GenServer.server()) :: map()
Returns session statistics for monitoring.
Example
iex> ExWapp.stats(session)
%{
status: :connected,
connected_at: ~U[2024-01-15 10:30:00Z],
uptime_seconds: 3600,
messages_sent: 42,
messages_received: 128,
reconnects: 1,
errors: 0
}
@spec status(ExWapp.Client.t()) :: ExWapp.Client.status()
Returns the status of a high-level client.
@spec stream_calls( GenServer.server(), keyword() ) :: Enumerable.t()
Builds a deferred stream over the locally stored call log, oldest first.
The bounded retained log is loaded and sorted when enumeration starts, so
creating the stream itself performs no store read. Accepts the same options
as ExWapp.Call.list/2.
@spec stream_messages(ExWapp.Client.t() | GenServer.server(), String.t(), keyword()) :: {:ok, Enumerable.t()} | {:error, ExWapp.Error.t(), ExWapp.Client.t()} | Enumerable.t()
Builds a lazy stream over locally retained messages.
The default order is oldest first, which is suitable for inserting history into a wrapper database. Message payloads are read from the dedicated message store one at a time; the complete history is not stored in or loaded through the chat struct.
Options
:order-:oldest_first(default) or:newest_first:offset- Number of records to skip (default: 0):limit- Optional maximum; defaults to:all
This stream represents local history and does not request older messages from WhatsApp.
Subscribe the calling process to a session's events via PubSub.
Subscribes to both incoming messages and session lifecycle events in a single call. Accepts either a session pid or a session_id string.
Messages received
{:ex_wapp_message, jid, message}- incoming WhatsApp message{:ex_wapp_call, call}- call state change (%ExWapp.Call{}: ringing, answered, missed, ...){:ex_wapp_session, :connected}- session connected{:ex_wapp_session, :error, reason}- session-level error event{:ex_wapp_session, :disconnected, reason}- session disconnected{:ex_wapp_session, :terminated, reason}- session process stopped{:ex_wapp_health, type, metadata}- send health/error/guard events
Example
ExWapp.subscribe(session)
# or
ExWapp.subscribe("my_session")
def handle_info({:ex_wapp_message, jid, message}, state) do
IO.puts("Message from #{jid}")
{:noreply, state}
end
def handle_info({:ex_wapp_session, :connected}, state) do
{:noreply, assign(state, :status, :connected)}
end
def handle_info({:ex_wapp_session, :disconnected, _reason}, state) do
{:noreply, assign(state, :status, :disconnected)}
end
@spec subscribe_all() :: :ok | {:error, term()}
Subscribe to events from ALL sessions via PubSub.
Subscribes to both global messages and global lifecycle topics.
Messages received
{:ex_wapp_message, session_id, jid, message}- message from any session{session_id, {:ex_wapp_call, call}}- call state change from any session{session_id, {:ex_wapp_session, event, ...}}- lifecycle from any session{session_id, {:ex_wapp_health, type, metadata}}- health events from any session
@spec sync_contacts(ExWapp.Client.t() | GenServer.server()) :: {:ok, ExWapp.Client.t()} | {:error, ExWapp.Error.t(), ExWapp.Client.t()} | :ok | {:error, term()}
Fetches and applies the contacts collection from app state (critical_unblock_low).
@spec unarchive_chat(GenServer.server(), String.t()) :: :ok | {:error, term()}
Unarchives a chat (server-synced via app state).
@spec unmute_chat(GenServer.server(), String.t()) :: :ok | {:error, term()}
Unmutes a chat (server-synced via app state).
@spec unpin_chat(GenServer.server(), String.t()) :: :ok | {:error, term()}
Unpins a chat (server-synced via app state).
Unsubscribe the calling process from a session's events.
Unsubscribes from both messages and lifecycle topics. Accepts either a session pid or a session_id string.
@spec unsubscribe_all() :: :ok
Unsubscribe from all global topics (messages + lifecycle).