Irish
WhatsApp Web client for Elixir, powered by Baileys.
Irish runs Baileys inside a Deno subprocess and talks to it over a JSON-lines protocol on stdio. Incoming WhatsApp events land in your GenServer as plain Elixir messages with structured data. Outgoing commands are synchronous calls that return when WhatsApp responds.
Elixir GenServer ──stdio──> Deno (bridge.ts) ──WebSocket──> WhatsAppPrerequisites
- Elixir >= 1.18
- Deno >= 2.0 (
brew install deno/ deno.land) - npm (ships with Node.js — only needed to install Baileys once)
Installation
Add irish to your deps:
def deps do
[
{:irish, "~> 1.0"}
]
endThen fetch deps and install the Baileys bridge:
mix deps.get
mix irish.setupmix irish.setup runs npm install inside Irish's priv/ directory to fetch
Baileys and its dependencies. You only need to run it once (and again after
upgrading Irish).
Quick start
# Start a connection — events go to the calling process
{:ok, wa} = Irish.start_link(auth_dir: "/tmp/wa_auth", handler: self())
# First connection: scan the QR code
receive do
{:wa, "connection.update", %{"qr" => qr}} ->
# print or render the QR string
IO.puts("Scan this QR code in WhatsApp > Linked Devices:\n#{qr}")
end
# Wait for the connection to open
receive do
{:wa, "connection.update", %{"connection" => "open"}} ->
IO.puts("Connected!")
end
# Send a text message
{:ok, %Irish.Message{} = msg} =
Irish.send_message(wa, "15551234567@s.whatsapp.net", %{text: "Hello from Elixir!"})
# Send an image from a URL
{:ok, _} = Irish.send_message(wa, "15551234567@s.whatsapp.net", %{
image: %{url: "https://example.com/photo.jpg"},
caption: "Check this out"
})
After the first QR scan, credentials are saved to auth_dir. Subsequent
starts reconnect automatically — no QR needed.
Pairing code (no QR scan)
If you prefer phone-number-based pairing:
{:ok, wa} = Irish.start_link(auth_dir: "/tmp/wa_auth", handler: self())
# Wait for connection to reach "connecting" state, then request a code
receive do
{:wa, "connection.update", %{"connection" => "connecting"}} -> :ok
end
{:ok, code} = Irish.request_pairing_code(wa, "15551234567")
IO.puts("Enter this code in WhatsApp: #{code}")Receiving messages
All Baileys events arrive as {:wa, event_name, data} messages. By default,
event data is converted to typed structs (see Data types below).
def handle_info({:wa, "messages.upsert", %{messages: messages, type: :notify}}, state) do
for msg <- messages do
sender = Irish.Message.from(msg)
text = Irish.Message.text(msg)
type = Irish.Message.type(msg)
if text do
IO.puts("[#{type}] #{msg.push_name} (#{sender}): #{text}")
end
# React to media messages
if Irish.Message.media?(msg) do
Irish.react(state.conn, msg.key.remote_jid, msg.key, "👀")
end
end
{:noreply, state}
end
def handle_info({:wa, "contacts.upsert", contacts}, state) do
for %Irish.Contact{} = contact <- contacts do
IO.puts("New contact: #{Irish.Contact.display_name(contact)}")
end
{:noreply, state}
end
def handle_info({:wa, "presence.update", %{id: chat_id, presences: presences}}, state) do
for {jid, %Irish.Presence{last_known_presence: presence}} <- presences do
IO.puts("#{jid} is #{presence} in #{chat_id}")
end
{:noreply, state}
end
def handle_info({:wa, "call", calls}, state) do
for %Irish.Call{} = call <- calls do
kind = if call.is_video, do: "video", else: "voice"
IO.puts("Incoming #{kind} call from #{call.from}: #{call.status}")
end
{:noreply, state}
end
def handle_info({:wa, "connection.update", %{"connection" => "close"}}, state) do
IO.puts("Disconnected — supervisor will restart")
{:noreply, state}
end
# Catch-all for events you don't care about
def handle_info({:wa, _event, _data}, state), do: {:noreply, state}Opting out of structs
If you prefer raw maps (the Baileys JSON as-is), pass struct_events: false:
{:ok, wa} = Irish.start_link(
auth_dir: "/tmp/wa_auth",
handler: self(),
struct_events: false
)
# Events now arrive as raw camelCase maps:
receive do
{:wa, "messages.upsert", %{"messages" => messages, "type" => "notify"}} ->
for msg <- messages do
text = get_in(msg, ["message", "conversation"])
IO.puts("#{msg["key"]["remoteJid"]}: #{text}")
end
endSupervision
Add Irish to your supervision tree for automatic restarts:
children = [
{Irish, auth_dir: "/tmp/wa_auth", handler: MyApp.WAHandler, name: :whatsapp}
]
Supervisor.start_link(children, strategy: :one_for_one)Then call functions by name:
Irish.send_message(:whatsapp, jid, %{text: "hello"})Data types
Irish converts Baileys' camelCase JSON maps into Elixir structs with
snake_case fields. All structs provide a from_raw/1 function for manual
conversion.
| Struct | Description | Key fields |
|---|---|---|
Irish.Message | A WhatsApp message | key, message, push_name, status, message_timestamp |
Irish.MessageKey | Identifies a specific message | remote_jid, from_me, id, participant |
Irish.Contact | A WhatsApp contact | id, name, notify, verified_name, phone_number |
Irish.Chat | A conversation | id, name, unread_count, archived, pinned |
Irish.Group | Group metadata | id, subject, owner, description, participants |
Irish.Group.Participant | A group member | id, phone_number, admin |
Irish.Presence | Online/typing status | last_known_presence, last_seen |
Irish.Call | A call event | id, from, status, is_video, is_group |
Message helpers
Irish.Message provides helpers for common access patterns:
msg = List.first(messages)
Irish.Message.text(msg) # => "Hello!" — extracts text from any message type
Irish.Message.type(msg) # => :text — content type atom
Irish.Message.media?(msg) # => false — true for image/video/audio/document/sticker
Irish.Message.from(msg) # => "15551234567@s.whatsapp.net" — sender JID
Content type atoms: :text, :image, :video, :audio, :document,
:sticker, :location, :live_location, :contact, :contacts,
:reaction, :poll, :view_once, :ephemeral, :edited, :protocol,
:unknown.
Message status atoms: :error, :pending, :server_ack, :delivery_ack,
:read, :played.
MessageKey
Irish.MessageKey round-trips between structs and the raw maps the bridge
expects:
# From an event
key = msg.key # => %Irish.MessageKey{remote_jid: "...", from_me: false, id: "..."}
# Use directly in API calls
Irish.react(conn, key.remote_jid, key, "👍")
Irish.read_messages(conn, [key])
# Convert back to raw map if needed
Irish.MessageKey.to_raw(key) # => %{"remoteJid" => "...", "fromMe" => false, "id" => "..."}Contact helpers
Irish.Contact.display_name(contact)
# Returns first non-nil of: name, notify, verified_name, phone_number, idAPI reference
Messaging
| Function | Returns | Description |
|---|---|---|
send_message(conn, jid, content, opts \\ %{}) | {:ok, %Message{}} | Send any message type |
read_messages(conn, keys) | {:ok, any} |
Mark messages as read (accepts %MessageKey{} or raw maps) |
react(conn, jid, key, emoji) | {:ok, %Message{}} |
React to a message (accepts %MessageKey{} or raw maps) |
unreact(conn, jid, key) | {:ok, %Message{}} | Remove a reaction |
send_receipts(conn, keys, type) | {:ok, any} |
Send read/played receipts (accepts %MessageKey{} or raw maps) |
send_presence(conn, type, jid \\ nil) | {:ok, any} |
Send "composing", "available", etc. |
presence_subscribe(conn, jid) | {:ok, any} | Get notified of a contact's presence |
download_media(conn, message) | {:ok, binary} | Download and decrypt media |
Profile
| Function | Description |
|---|---|
profile_picture_url(conn, jid) | Get profile picture URL |
update_profile_status(conn, text) | Update your status/about |
update_profile_name(conn, name) | Update your display name |
fetch_status(conn, jids) | Get status text for JIDs |
on_whatsapp(conn, phone_numbers) | Check if numbers are registered |
Groups
| Function | Returns | Description |
|---|---|---|
group_metadata(conn, jid) | {:ok, %Group{}} | Get group info |
group_create(conn, subject, participants) | {:ok, %Group{}} | Create a group |
group_fetch_all(conn) | {:ok, [%Group{}]} | List all groups you're in |
group_get_invite_info(conn, code) | {:ok, %Group{}} | Info about an invite link |
group_update_subject(conn, jid, subject) | Rename a group | |
group_update_description(conn, jid, desc) | Update group description | |
group_participants_update(conn, jid, participants, action) | "add", "remove", "promote", "demote" | |
group_invite_code(conn, jid) | Get invite link code | |
group_leave(conn, jid) | Leave a group |
Privacy
| Function | Description |
|---|---|
update_block_status(conn, jid, action) | "block" or "unblock" |
fetch_blocklist(conn) | Get blocked JIDs |
Auth
| Function | Description |
|---|---|
request_pairing_code(conn, phone, code \\ nil) | Phone-based pairing (no QR) |
logout(conn) | Log out and invalidate session |
Events
Key events you'll receive as {:wa, event_name, data}:
| Event | Struct shape | When |
|---|---|---|
"connection.update" | raw map | Connection state changes, QR codes |
"messages.upsert" | %{messages: [%Message{}], type: :notify | :append} | New messages |
"messages.update" | [%{key: %MessageKey{}, update: map}] | Delivery/read receipts |
"messages.delete" | %{keys: [%MessageKey{}]} | Deleted messages |
"messages.reaction" | [%{key: %MessageKey{}, reaction: map}] | Reactions |
"message-receipt.update" | [%{key: %MessageKey{}, receipt: map}] | Granular receipts |
"chats.upsert" | [%Chat{}] | New chats appear |
"chats.update" | [%Chat{}] | Chat metadata changes |
"contacts.upsert" | [%Contact{}] | New contacts |
"contacts.update" | [%Contact{}] | Contact changes |
"groups.upsert" | [%Group{}] | New groups |
"groups.update" | [%Group{}] | Group metadata changes |
"group-participants.update" | raw map | Members added/removed/promoted |
"presence.update" | %{id: jid, presences: %{jid => %Presence{}}} | Typing, online status |
"call" | [%Call{}] | Incoming calls |
"creds.update" | raw map | Session credentials changed |
"messaging-history.set" | raw map | History sync chunks |
Options
| Option | Default | Description |
|---|---|---|
:auth_store | file store | {module, opts} — custom auth persistence (guide) |
:auth_dir | "./wa_auth" | Directory to store WhatsApp session (shorthand for file store) |
:handler | caller PID |
PID to receive {:wa, event, data} messages |
:name | none | Optional registered name for the process |
:config | %{} | Baileys socket config overrides |
:timeout | 30_000 | Default command timeout in ms |
:struct_events | true |
Convert event data to structs (false for raw maps) |
JID format
WhatsApp identifies users and groups by JID:
- User:
15551234567@s.whatsapp.net(country code + number, no+or spaces) - Group:
120363001234567890@g.us - Status broadcast:
status@broadcast
Message content types
The content argument to send_message/4 is a map matching Baileys'
AnyMessageContent. Common shapes:
# Text
%{text: "Hello!"}
# Image (from URL)
%{image: %{url: "https://..."}, caption: "optional"}
# Video
%{video: %{url: "https://..."}, caption: "optional"}
# Document
%{document: %{url: "https://..."}, mimetype: "application/pdf", fileName: "doc.pdf"}
# Audio voice note
%{audio: %{url: "https://..."}, ptt: true}
# Location
%{location: %{degreesLatitude: 40.7128, degreesLongitude: -74.0060}}
# Contact
%{contacts: %{displayName: "Jane", contacts: [%{vcard: "BEGIN:VCARD\n..."}]}}
# Reaction (accepts %MessageKey{} struct or raw map)
%{react: %{text: "👍", key: msg.key}}
# Reply (pass original message as option)
Irish.send_message(conn, jid, %{text: "replying!"}, %{quoted: original_msg})How it works
Irish spawns a Deno process running bridge.ts, which creates a Baileys
WhatsApp socket and bridges it to Elixir over stdin/stdout:
Events (WhatsApp -> Elixir): Baileys emits events; the bridge serializes them as JSON lines on stdout; the GenServer decodes them, converts to structs via
Irish.Event, and forwards to your handler as{:wa, event, data}.Commands (Elixir -> WhatsApp):
Irish.send_message/4etc. write a JSON command with a correlation ID to the bridge's stdin. The bridge calls the corresponding Baileys method and writes the result back. The GenServer matches the response to the waiting caller.Reconnection: If the WhatsApp connection drops (but isn't a logout), the bridge reconnects internally. If the bridge process itself crashes, the GenServer exits and your supervisor restarts it.
Auth state: Credentials and Signal keys are persisted through the
Irish.Auth.Storebehaviour. By default, files inauth_dir(matching Baileys' layout). Passauth_store: {MyStore, opts}for database or other backends — see the Custom Auth Stores guide.
Guides
- Building a GenServer Handler — supervision, connection lifecycle, reconnection, test mode
- Custom Auth Stores — store credentials in a database, Redis, S3, or any custom backend
- Common Patterns — event filtering, message processing, media downloads, groups, LID translation, error handling, telemetry
- Event Reference — typed event structs, compatibility policy
License
MIT