Skip to content

Repository files navigation

WhatsApp Bridge

An async-first Python client for the WhatsApp account you link. Register events, send messages, work with media and groups, and reuse named sessions.

pip install whatsapp-bridge

Version 1.0 is a breaking rewrite. The original 0.1 codebase does not have this API. Existing users should read the migration guide and 1.0.0 release notes before upgrading.

from whatsapp_bridge import WhatsAppClient, events

client = WhatsAppClient("personal")


@client.on(events.NewMessage(incoming=True))
async def handler(event):
    print(event.sender, event.text)
    if event.text.lower() == "ping":
        await event.reply("pong")


client.run_until_disconnected()

On first connection, scan the terminal QR using WhatsApp β†’ Linked Devices β†’ Link a Device. Subsequent connections reuse the saved linked-device identity, unless WhatsApp removes or invalidates it. There are no separate WhatsApp bot accounts: all actions use the account you explicitly link.

This uses unofficial WhatsApp Web linked-device functionality, not the WhatsApp Business API. It is not affiliated with WhatsApp or Meta. Protocol changes, account restrictions, privacy settings, and WhatsApp permissions can affect availability. Use automation only with appropriate consent.

Installation and platforms

Python 3.11–3.14. Release wheels target Windows x86-64, macOS 13+ Intel/Apple Silicon, and Linux x86-64 (glibc 2.17+ build target). The native Go shared library and SQLite are bundled in each wheel. The only separately installed Python runtime dependency is segno for QR rendering.

Installed wheels require no Go, Git, C compiler, Docker, HTTP bridge, or browser. There are no runtime downloads of executables. Unsupported platforms fail with a clear native-library error. Source builds require developer toolchains; supported wheel platforms and actual verification are tracked in validation.

The backend is a small owned in-process whatsmeow adapter, isolated behind a private interface. The requested Neonize option was audited and rejected for specific native lifecycle and crash defects in its published release. See the repository and backend audit for versions, source links, and the comparison with other in-process options.

Linked-device name

The linked-device name defaults to Sree, configured once in src/whatsapp_bridge/config.py. Override it per client without editing the backend:

from whatsapp_bridge import ClientConfig, WhatsAppClient

client = WhatsAppClient("personal", config=ClientConfig(device_name="My Bridge"))

This name is sent when pairing; it is separate from the local session name (personal). To change an existing device's displayed name, unlink that device in WhatsApp and pair again with the new configuration. WhatsApp controls the final display and icon.

Async usage and lifecycle

import asyncio
from whatsapp_bridge import WhatsAppClient


async def main():
    async with WhatsAppClient("personal") as client:
        message = await client.send_message("+919876543210", "Hello!")
        print(message.id)


asyncio.run(main())

connect() waits for authentication. disconnect() closes resources and keeps the identity. logout() explicitly unlinks the account. me holds normalized device information after connection. is_connected and connection_state describe local state; send acknowledgement is not a recipient read receipt.

For a long-running async application:

async with WhatsAppClient("personal") as client:
    await client.wait_until_disconnected()

whatsmeow owns network reconnects and increasing retry delays. This package adds a finite reconnect deadline, stops on permanent authentication failures, and cancels reconnects during shutdown. Sends are never automatically retried: a timeout can mean WhatsApp accepted a message but its acknowledgement was lost.

run_until_disconnected() is the blocking script entry point. Use the async API inside notebooks, web frameworks, and existing loops. There is no separate sync client implementation. Keep a client on the loop where it first connected; create a new client instance when creating a new loop.

Events and filters

@client.on(
    events.NewMessage(
        incoming=True,
        private=True,
        from_users=["+919876543210"],
        pattern=r"^hello\b",
        has_media=False,
    )
)
async def greeting(event):
    await event.reply("Hey!")

Filters: incoming, outgoing, private, groups, from_users, chats, pattern, and has_media. Booleans require the specified value. Patterns use re.search, and the match is available as event.pattern_match. Filter entities are numbers/JIDs, not display names. Contradictory flags are rejected.

Events: NewMessage, MessageEdited, MessageDeleted, Reaction, Receipt, Presence, Typing, Connected, Disconnected, LoggedOut, HistorySync, GroupUpdate, ContactUpdate, and Call. Call events are notifications only. Historical messages are stored before HistorySync and never replayed as NewMessage. Acknowledged local sends also produce outgoing NewMessage events.

Handlers are async functions, dispatched in registration order. Handler failures are logged and do not stop other handlers. Connection/storage failures stop the client and surface through the lifecycle waits. Keep handlers short: queues are bounded and an EventOverflowError stops an overloaded client instead of silently dropping events or growing memory indefinitely. Resume logic should reconcile the local archive; this is not an exactly-once durable job queue.

client.remove_event_handler(handler) removes a registered function.

Message objects and sending

event.message and send results expose id, text, sender, chat, timestamp (UTC), is_group, is_private, is_from_me, media, quoted_message, mentions, sender_name, is_edit, and expires_at. sender_alt and chat_alt retain alternate addresses only when WhatsApp supplies them. Phone filters/history queries can match these known aliases; a LID alone does not establish a phone-number association. event.text, event.sender, and event.chat are convenient shortcuts.

sent = await client.send_message("+919876543210", "Hello!")
await sent.react("πŸ‘")
edited = await sent.edit("Hello again!")
await edited.delete()  # revoke for everyone, subject to WhatsApp rules

await client.send_message(group_id, "Hi @919876543210", mentions=["+919876543210"])
await client.send_location("+919876543210", 12.9716, 77.5946, name="Meeting point")
await client.send_contact("+919876543210", "Alice", "+14155550100")
await client.send_poll(group_id, "Lunch?", ["12:00", "13:00"], selectable_count=1)

Reply with await event.reply(text) or await client.send_message(entity, text, reply_to=message).

Numbers need a country code; no region is guessed. Bare/device JIDs and group, LID, newsletter, and broadcast IDs normalize internally. LIDs remain privacy identifiers and are never fabricated into phone numbers. Sending is supported to private chats and groups. Newsletter/status/broadcast sending and management are deliberately not exposed in this release.

resolve_entity("DevsDoCode") looks up an exact known contact/chat/group name. Ambiguous names raise EntityNotFoundError; numbers and JIDs resolve directly.

Media

from whatsapp_bridge.types import MediaType

await client.send_file("+919876543210", "photo.jpg", caption="Look at this")
await client.send_file("+919876543210", b"report data", filename="report.txt")
await client.send_file("+919876543210", "voice.ogg", voice_note=True)
await client.send_file("+919876543210", "sticker.webp", media_type=MediaType.STICKER)
path = await event.download_media()  # returns pathlib.Path

Accepts paths, bytes, and binary streams. Supply filename/mime_type for bytes when inference is insufficient; media_type=MediaType.DOCUMENT forces attachment delivery. Caller-owned streams stay open. Downloads use safe unique filenames; an explicit file path must not already exist. Returned files belong to the caller and are not temporary files deleted behind their back.

Image/video/document captions, audio, Ogg Opus voice notes, WebP stickers, and MP4 GIF playback are supported. Supply duration for audio/video metadata when known. The package sends pre-encoded files; it does not run FFmpeg, transcode arbitrary GIFs, or generate previews. View-once payloads are not archived or made downloadable. Expired messages are excluded from archive queries.

History and chats

messages = await client.get_messages("+919876543210", limit=100, search="meeting")
async for message in client.iter_messages("+919876543210", limit=1000):
    print(message.timestamp, message.text)

chats = await client.get_chats()
contacts = await client.get_contacts()
contact = await client.get_contact("+919876543210")

History is the local synchronized archive, newest first. WhatsApp decides what reaches a linked device; this API cannot retrieve arbitrary old messages from the phone/server. It stores observed and synced messages in its own schema, never polls or reads the native identity database. The default retention is 10,000 messages per session across all chats. Set history_limit=0 to disable message retention. Expired records are hidden immediately by query predicates and physically pruned during archive writes. Bounded deletion markers prevent recent revocations being restored by a later history batch. They contain chat/message IDs and timestamps, not message contents.

Filters: limit, exclusive UTC-aware before/after, search (case-insensitive SQLite substring matching), sender, and media_type. Pagination preserves messages sharing a timestamp. A disconnected client must be reconnected to use its archive through this API.

Chats combine synchronized/observed chats with joined groups. Chat exposes id, name, last_message, is_group, and is_private. Unread counts are not exposed because the library cannot maintain a reliably authoritative count.

Groups and presence

groups = await client.get_groups()
group = await client.get_group(group_id)
created = await client.create_group("Project", ["+919876543210"])
results = await client.update_participants(group_id, ["+919876543210"], "add")
await client.set_group_name(group_id, "New subject")
await client.set_group_description(group_id, "Project notes")
await client.set_group_photo(group_id, "photo.jpg")
link = await client.get_invite_link(group_id)
await client.join_group(link)
await client.leave_group(group_id)

await client.set_presence(online=True)
await client.subscribe_presence("+919876543210")
await client.set_typing(group_id, "composing")  # also recording / paused
await event.message.mark_read()

Participant actions are add, remove, promote, and demote. Inspect each returned participant's error_code: a group operation can partially succeed. Permission failures use errors.PermissionError. WhatsApp's privacy/consent and admin rules still apply. Presence events require subscriptions; typing events usually require the linked account to be available. get_profile_picture() returns picture metadata/URL without fetching another site automatically.

Sessions, QR hooks, and configuration

from whatsapp_bridge import ClientConfig, WhatsAppClient


async def show_qr(qr):
    # qr.text: sensitive pairing text; qr.png(): image bytes; qr.terminal(): text
    await my_application.display_qr(qr.png())


client = WhatsAppClient(
    "personal", qr_callback=show_qr, config=ClientConfig(history_limit=5000, reconnect_timeout=120)
)

Defaults: Windows %LOCALAPPDATA%/whatsapp-bridge, macOS ~/Library/Application Support/whatsapp-bridge, Linux ${XDG_DATA_HOME:-~/.local/share}/whatsapp-bridge. Override with session_dir. Each named directory has an identity database, archive, and OS-backed lease. Different names are independent; concurrent reuse of one name fails. There is no import-time filesystem mutation or shared global configuration. Archives are bound to the authenticated account's phone JID. Linking a different account to an existing named archive fails instead of mixing private histories; use another name and unlink any unused device in WhatsApp's Linked Devices screen. Use a new name after changing the account's phone number as well.

Session files and QR codes are credentials. Protect them and their backups. POSIX session directories/files use 0700/0600. Windows uses the user's inherited directory ACL; store sessions in a private user directory. Databases are local and not encrypted at rest. Nothing is sent to an LLM, analytics service, or third-party bridge. Corrupt sessions produce SessionError and are never silently deleted. Logout keeps local history; remove a closed session directory yourself if you also want to erase its archive.

ClientConfig also sets connect_timeout, request_timeout, shutdown_timeout, event_queue_size, and max_media_bytes (default 64 MiB). Native Go requests use deadlines; shutdown cancels their context and waits for ownership to end. shutdown_timeout bounds terminal logout handlers. Other handlers must cooperate with asyncio cancellation.

Errors, logging, and scope

from whatsapp_bridge.errors import WhatsAppBridgeError

try:
    await client.send_message("+919876543210", "Hello")
except WhatsAppBridgeError:
    # Decide whether to report, wait, or reconcile an ambiguous send result.
    raise

The exception hierarchy includes authentication, connection, session, entity, send, media, permission, rate-limit, backend, unsupported-feature, and overload errors. Configure Python logging in your application; the package adds no root handlers. QR display is the deliberate terminal-output exception.

There is no call answering/recording, newsletter management, arbitrary historical fetch, link-preview scraping, communities manager, or status publishing API. These are not simulated with unsupported operations. Live-account verification is separate from ordinary tests. See validation, examples, and the 0.1 migration guide.

Development

See CONTRIBUTING.md for native builds, tests, lint, typing, and release wheels. The project is licensed under MPL-2.0; bundled components are recorded in third-party notices.

About

Lightweight full-featured Python WhatsApp Web API

Topics

Resources

Contributing

Stars

31 stars

Watchers

1 watching

Forks

Releases

Contributors

Languages