Skip to main content

WhatsApp client for Python, powered by Rust. A thin PyO3 wrapper over whatsapp-rust.

Project description

wars

WhatsApp client for Python, powered by Rust. A thin PyO3 wrapper over whatsapp-rust (vendored as a submodule). Drop it into any Python app to send and receive WhatsApp messages — no Node.js sidecar, no separate server, no IPC.

wars is an unofficial library — it is not built by, affiliated with, endorsed by, or sponsored by WhatsApp / Meta. "WhatsApp" and related trademarks belong to their respective owners.

pip install wars

Why

  • No Node.js required. A single pip install ships native code; the WhatsApp Web protocol (Noise handshake, Signal Protocol, protobuf) runs in Rust inside your Python process.
  • Sync API. Call wa.send(...) from any Flask handler. No asyncio, no background server, no IPC.
  • No files by default. In-memory session out of the box — zero filesystem permissions to set. When you're ready, dump the session as bytes and stash it in your existing database.
  • One unified send(). Same call for text, images, documents, groups, and broadcasts. Bot mode with an @on_message decorator.

Quick start

1. Pair your phone (one-time)

In a Jupyter notebook — one cell, end-to-end:

from wars import WhatsApp

wa = WhatsApp()
wa.pair()                                # QR shows inline; scan it
wa.send("Hello from wars")               # works after pair

wa.pair() blocks until the device is paired or 5 minutes elapse, displays a fresh QR image each time WhatsApp rotates it (~every 30s), and prints any pair code WhatsApp issues. In a terminal it falls back to an ASCII QR automatically.

From the command line — useful for headless setup:

curl -O https://raw.githubusercontent.com/marketcalls/wars/main/examples/pair.py
python pair.py --phone 919876543210

Use either:

  • QR: WhatsApp on phone → Linked devicesLink a device → scan.
  • Code: Linked devicesLink with phone number → type the code.

By default nothing is written to disk — the session is in-memory and will be lost when the script exits. For persistence pick one:

python pair.py --phone 919876543210 --db whatsapp.db    # SQLite file
python pair.py --phone 919876543210 --save-png qr.png   # also dump QR PNG

For the cleanest production flow, leave the script in-memory and stash the session bytes into your own DB — see Persist the session below.

2. Send a message

One unified send() for every pattern. Single-arg send("text") automatically routes to your own number after pairing — perfect for personal alerts.

from wars import WhatsApp

# No db_path → in-memory session. Re-pair if the process restarts.
wa = WhatsApp()
wa.connect()
wa.wait_until_ready()

# Single-arg send → goes to owner (yourself)
wa.send("Hello from wars")
wa.send(f"Build #{build_id} finished in {elapsed:.1f}s")

# Send to a specific contact
wa.send("14155550199", "Are you free for a quick call?")

# Image with caption
wa.send("14155550100", image="screenshot.png", caption="Latest dashboard")

# Document
wa.send("14155550100", document="report.pdf")

# Group message
wa.send_group("120363012345678901@g.us", "Daily standup in 5 minutes")

# Broadcast the same body to many recipients
wa.send([(n, "Server maintenance starting now") for n in oncall])

3. Persist the session in your own database

In-memory is simplest but you re-pair on every restart. For production, keep the paired session as bytes inside your existing app database, an encrypted column in a secret store, or anywhere you already keep API keys.

import os, sqlite3, tempfile

# First run — pair to a short-lived temp file, then dump bytes once.
# (export_session() needs a file-backed DB; Rust + Python use separate
# SQLite library instances so an in-memory DB can't be snapshotted from
# Python. The temp file is 0600 and deleted as soon as we have the bytes.)
fd, pair_db = tempfile.mkstemp(suffix=".db", prefix="wars_pair_")
os.close(fd); os.chmod(pair_db, 0o600)

wa = WhatsApp(pair_db)
wa.connect(); wa.wait_until_ready()              # scan QR
blob = wa.export_session()                       # ~300 KB bytes
wa.disconnect()
os.unlink(pair_db)                               # clean up

# Stash wherever — example: a tiny table in your app's SQLite
db = sqlite3.connect("app.db")
db.execute("CREATE TABLE IF NOT EXISTS wa (id INTEGER PRIMARY KEY, blob BLOB)")
db.execute("INSERT OR REPLACE INTO wa VALUES (1, ?)", (blob,)); db.commit()

# Every run after — load and resume. No QR.
blob = db.execute("SELECT blob FROM wa WHERE id = 1").fetchone()[0]
wa = WhatsApp.from_bytes(blob)
wa.connect()                                     # instant reconnect

See examples/webapp_integration.py for the full pattern including a pair CLI subcommand, lazy singleton, and Flask integration.

4. Show the pairing QR in a browser (no files)

For a browser-based pairing screen, convert the QR to a base64 data URL and embed it in HTML — no PNG ever touches the disk:

from wars import WhatsApp, qr_to_data_url

wa = WhatsApp()
latest_qr = {"data_url": None}

@wa.on_qr
def cache(code):
    latest_qr["data_url"] = qr_to_data_url(code)

wa.connect()

# Flask route
@app.get("/pair-qr")
def pair_qr():
    return {"img": latest_qr["data_url"]}      # JSON for SPAs

# Or render directly:
#   <img src="{{ qr.data_url }}" />            # works because it's a data URL

Also available: qr_to_base64(code) for the raw base64 string without the data: prefix.

5. Or: just use a file path

If you want SQLite-on-disk (simpler than DB-stored bytes for solo runs), pass a path:

wa = WhatsApp("whatsapp.db")

The file lives wherever the path points. Treat it like any other secret (restrict permissions, exclude from git — .gitignore already covers *.db).

6. Receive messages (bot mode)

from wars import WhatsApp, Message

wa = WhatsApp("whatsapp.db")

@wa.on_message
def handle(msg: Message):
    if msg.is_from_me:
        return  # ignore echoes of our own sends
    if msg.text == "/ping":
        wa.send(msg.chat, "pong")
    elif msg.text == "/status":
        wa.send(msg.chat, f"Uptime: {get_uptime()}")
    elif msg.text.startswith("/echo "):
        wa.send(msg.chat, msg.text.removeprefix("/echo "))

wa.connect()
wa.run_forever()

7. Integrate into a web app

Singleton pattern — one connection per process, send from any route. See examples/webapp_integration.py for the full sketch.

# yourapp/whatsapp.py
import atexit
from threading import Lock
from wars import WhatsApp

_wa: WhatsApp | None = None
_lock = Lock()
OWNER = "14155550100"

def wa() -> WhatsApp:
    """Lazy singleton — first call pairs/connects, rest are free."""
    global _wa
    if _wa is not None:
        return _wa
    with _lock:
        if _wa is not None:
            return _wa
        client = WhatsApp("whatsapp.db")
        client.connect()
        client.wait_until_ready(timeout=60)
        atexit.register(client.disconnect)
        _wa = client
        return _wa
# any Flask blueprint
from yourapp.whatsapp import wa

@app.post("/webhook/build")
def build_done():
    data = request.json
    wa().send(f"Build #{data['id']} {data['status']} in {data['duration']}s")
    return "ok"

Works the same way under Django (call from AppConfig.ready()), FastAPI (wrap blocking calls with asyncio.to_thread(wa().send, ...)), Streamlit, Dash, etc.

⚠️ One process only. WhatsApp Web is one-device-per-session. Run gunicorn -w 1 --threads 8 or equivalent — don't fork multiple workers all using the same whatsapp.db, or Meta will unlink the device.

API

WhatsApp(db_path=None, log_level=None)

Construct a client. Does not connect.

  • db_path=None (default) — in-memory session, no filesystem touched.
  • db_path="path.db" — SQLite on disk, session survives restarts.

Single-arg send("text") defaults to the device's own phone number (read from the paired-device record). No explicit owner is needed — just call wa.pair() first.

WhatsApp.from_bytes(blob)

Class method. Restore a session previously exported with export_session(). Use this to load a paired session from your own database.

Method Returns Notes
pair(phone=None, timeout=300) None One-call interactive pairing helper. Renders QR inline in Jupyter, ASCII in a terminal. Blocks until paired.
connect(phone=None) None Start background run loop. Optional E.164 digits enable pair-code auth.
wait_until_ready(timeout=120) None Block until paired+online. Raises TimeoutError.
is_connected() bool
disconnect() None Idempotent.
export_session() bytes Dump session for safe storage in your own DB / secret manager.
send(*args, image=, document=, caption=, filename=) message_id or list Unified API — see shapes below.
send_group(group_id, text) message_id: str Convenience for groups. group_id accepts "…@g.us" or bare digits.
send_text(to, text) message_id: str Explicit form. to accepts "919876543210", "+91 98765 43210", full JID, or group JID.
send_image(to, data, caption=None) message_id: str Explicit form. data: file path or bytes. MIME auto-sniffed.
send_document(to, data, filename=None, mimetype=None) message_id: str Explicit form.
on_qr(fn) decorator fn(qr_data: str)
on_pair_code(fn) decorator fn(code: str)
on_message(fn) decorator fn(msg: Message)
on_connected(fn) / on_disconnect(fn) decorator fn()
messages(timeout=1.0) iterator yields Message
events(timeout=1.0) iterator yields raw dicts
run_forever() None Block until Ctrl-C
print_qr(code) (static) None Render QR to stdout (terminal).
show_qr(code) (module fn) None Render QR inline in Jupyter (PNG), or ASCII in a terminal.
qr_to_base64(code) (static) str QR → base64 PNG. No filesystem I/O.
qr_to_data_url(code) (static) str QR → data:image/png;base64,… URL.

send() shapes

wa.send("alert text")                          # → owner
wa.send("919876543210", "alert text")          # → recipient
wa.send("919876543210", image="screenshot.png", caption="Dashboard")
wa.send("919876543210", document="report.pdf")
wa.send("120363…@g.us", "group msg")           # group JIDs work as-is
wa.send([(n, "msg") for n in subscribers])     # broadcast

Message (dataclass)

chat: str           # JID of the chat (1:1 or group)
sender: str         # JID of the person who sent it
is_group: bool
is_from_me: bool
id: str             # message ID
push_name: str      # sender's display name
timestamp: int      # unix seconds
text: str | None    # plain text content if any
media_type: str     # "" / "image" / "document" / ...

Building from source

git clone --recursive https://github.com/marketcalls/wars
cd wars
uv venv && source .venv/bin/activate
uv pip install maturin
maturin develop --release        # build + install into current venv

--recursive is required because wars vendors the upstream Rust crate as a submodule at vendor/whatsapp-rust. If you forgot it, run git submodule update --init --recursive after cloning.

The first build takes ~5 min (compiles the whole workspace); incremental builds are seconds.

To produce a wheel:

maturin build --release          # wheel lands in target/wheels/

To publish to PyPI:

maturin publish                  # needs PYPI_TOKEN

Wheels target Python 3.8+ via abi3 — one wheel per OS+arch covers every Python version from 3.8 onwards.

What's in v0.1

  • ✅ QR + pair-code auth, persistent SQLite session
  • ✅ Unified send() — text, image, document, groups, broadcast
  • send_group(), send_text(), send_image(), send_document() (explicit forms)
  • ✅ Receive messages via @on_message callback or wa.messages() iterator
  • ✅ All connection events: on_qr, on_pair_code, on_connected, on_disconnect
  • ✅ Built-in JID normalization (digits, +91 …, full JIDs, group JIDs)

Not in v0.1 (the Rust crate supports them; bindings to be added):

  • ❌ Group create/manage, reactions, edit/revoke, status, polls
  • ❌ Voice notes, video, contact lookup, presence
  • ❌ Async/await API (use asyncio.to_thread for now)

PRs welcome.

Credits

wars is a thin Python binding layer. The protocol work — Noise handshake, Signal Protocol, binary stanza encoding, media encryption, every byte that goes on the wire — is done by the projects below. If wars is useful to you, please go support them:

  • whatsapp-rust (originally by @jlucaso1) — the Rust client that this package wraps, vendored here as a fork for stable supply-chain. Without it, wars is nothing.
  • Baileys by the WhiskeySockets maintainers — the TypeScript reference implementation that whatsapp-rust learns from for protocol quirks, edge cases, and behavior parity.
  • whatsmeow by @tulir — the Go implementation that pioneered much of the multi-device protocol reverse-engineering.
  • @pokearaujo and @Sigalor for early observations of the WhatsApp Multi-Device and WhatsApp Web protocols that the above projects were built on.

The Python bindings layer (this package) is by Rajendran R / Marketcalls.

License

Copyright (c) 2026 Rajendran R / Marketcalls

Licensed under the MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

Thus, the maintainers of the project can't be held liable for any potential misuse of this project.

wars also bundles and statically links the upstream whatsapp-rust crate (a fork of @jlucaso1's original), also distributed under the MIT License (Copyright (c) 2025 João Lucas de Oliveira Lopes). See LICENSE for the full combined notice.

Disclaimer

This is an unofficial, open-source reimplementation. Using custom WhatsApp clients may violate Meta's Terms of Service and could result in account suspension. Use at your own risk.

This project is not affiliated, associated, authorized, endorsed by, or in any way officially connected with WhatsApp or any of its subsidiaries or its affiliates. The official WhatsApp website can be found at whatsapp.com. "WhatsApp" and related marks are registered trademarks of their respective owners.

The maintainers of wars do not in any way condone the use of this package in practices that violate the Terms of Service of WhatsApp. We call upon the personal responsibility of users to use this package fairly, as it is intended to be used. Do not spam people with this. We discourage any stalkerware, bulk, or automated mass-messaging usage.

WhatsApp Terms of Service — practical risk note

Unofficial WhatsApp clients can get the linked device unlinked or the entire account banned by Meta's automation. The dominant trigger is send volume and pattern, not the client itself:

  • Low risk (typical personal/automation usage) — a handful of notifications a day, the occasional /status reply to your own number or a small private group. This pattern is indistinguishable from a person using WhatsApp normally and stays well under Meta's automated thresholds.
  • Medium risk — sending to dozens of distinct contacts who haven't messaged you first, frequent broadcast lists, sending the same message body to many recipients in a short window.
  • High risk (don't) — bulk marketing, cold outreach to numbers you scraped, lookalike-spam patterns, evading rate limits. This is what triggers bans. Use the official WhatsApp Business / Cloud API for those use cases.

Treat your paired session file (whatsapp.db) as sensitive — it contains the private keys for your linked device. Anyone who gets a copy can impersonate that session.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distributions

No source distribution files available for this release.See tutorial on generating distribution archives.

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

wars-0.1.3-cp38-abi3-win_amd64.whl (7.0 MB view details)

Uploaded CPython 3.8+Windows x86-64

wars-0.1.3-cp38-abi3-manylinux_2_28_x86_64.whl (6.7 MB view details)

Uploaded CPython 3.8+manylinux: glibc 2.28+ x86-64

wars-0.1.3-cp38-abi3-manylinux_2_28_aarch64.whl (6.2 MB view details)

Uploaded CPython 3.8+manylinux: glibc 2.28+ ARM64

wars-0.1.3-cp38-abi3-macosx_11_0_arm64.whl (6.1 MB view details)

Uploaded CPython 3.8+macOS 11.0+ ARM64

wars-0.1.3-cp38-abi3-macosx_10_12_x86_64.whl (6.5 MB view details)

Uploaded CPython 3.8+macOS 10.12+ x86-64

File details

Details for the file wars-0.1.3-cp38-abi3-win_amd64.whl.

File metadata

  • Download URL: wars-0.1.3-cp38-abi3-win_amd64.whl
  • Upload date:
  • Size: 7.0 MB
  • Tags: CPython 3.8+, Windows x86-64
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for wars-0.1.3-cp38-abi3-win_amd64.whl
Algorithm Hash digest
SHA256 612d5bdf5a0d934d064ae4c0d8c549dbd065afac6a039f816a5a5d6fb7f122bd
MD5 73f92191f54eef397a0964ceeae17adf
BLAKE2b-256 78cbd0820994018014527f048b8f321d3064dd1c0a261e4f257ed9ff136b4810

See more details on using hashes here.

Provenance

The following attestation bundles were made for wars-0.1.3-cp38-abi3-win_amd64.whl:

Publisher: release.yml on marketcalls/wars

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file wars-0.1.3-cp38-abi3-manylinux_2_28_x86_64.whl.

File metadata

  • Download URL: wars-0.1.3-cp38-abi3-manylinux_2_28_x86_64.whl
  • Upload date:
  • Size: 6.7 MB
  • Tags: CPython 3.8+, manylinux: glibc 2.28+ x86-64
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for wars-0.1.3-cp38-abi3-manylinux_2_28_x86_64.whl
Algorithm Hash digest
SHA256 3fd4a40822a301f727b620c2e59ef04c86e65b6d36a80c446e6a0718244a112a
MD5 18e361e6a043c5b7cf19a2214cdd317e
BLAKE2b-256 536a18852e5159004246b00f836b3481e2a028aba50e02d6c3f71b63b746ede6

See more details on using hashes here.

Provenance

The following attestation bundles were made for wars-0.1.3-cp38-abi3-manylinux_2_28_x86_64.whl:

Publisher: release.yml on marketcalls/wars

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file wars-0.1.3-cp38-abi3-manylinux_2_28_aarch64.whl.

File metadata

File hashes

Hashes for wars-0.1.3-cp38-abi3-manylinux_2_28_aarch64.whl
Algorithm Hash digest
SHA256 aba35e9331bc0644ea535e0e2a99f169d8fce467680ccde89fa0823f79dfc44a
MD5 9d1b38c9310c4fe805a71c74186afe67
BLAKE2b-256 4401024d3898c6bc6c895f243e900fe431bbe5bbffa4a0e401bc3d75be279f77

See more details on using hashes here.

Provenance

The following attestation bundles were made for wars-0.1.3-cp38-abi3-manylinux_2_28_aarch64.whl:

Publisher: release.yml on marketcalls/wars

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file wars-0.1.3-cp38-abi3-macosx_11_0_arm64.whl.

File metadata

  • Download URL: wars-0.1.3-cp38-abi3-macosx_11_0_arm64.whl
  • Upload date:
  • Size: 6.1 MB
  • Tags: CPython 3.8+, macOS 11.0+ ARM64
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for wars-0.1.3-cp38-abi3-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 bf5f827106b9ca575e910eaf9542d95ffc8429ab14eb7f419e4409aeb0ceba56
MD5 1a3d8861774107ff12b312387c594e8d
BLAKE2b-256 a1dc28ddb0ccff13f34864ebbf57bacf3a703f03171f8628b44ce219ecc320e2

See more details on using hashes here.

Provenance

The following attestation bundles were made for wars-0.1.3-cp38-abi3-macosx_11_0_arm64.whl:

Publisher: release.yml on marketcalls/wars

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file wars-0.1.3-cp38-abi3-macosx_10_12_x86_64.whl.

File metadata

  • Download URL: wars-0.1.3-cp38-abi3-macosx_10_12_x86_64.whl
  • Upload date:
  • Size: 6.5 MB
  • Tags: CPython 3.8+, macOS 10.12+ x86-64
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for wars-0.1.3-cp38-abi3-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 31b261f52a8df71a3888f0c600bf577e195035ebcb2f2ab3cdf933de2d7520a1
MD5 34ab4b7edf6fd75a7ce07d172f918da4
BLAKE2b-256 529cc801518ec5b3edd5695601cccaac9eabcd4d702f111839072da2c4e97b3e

See more details on using hashes here.

Provenance

The following attestation bundles were made for wars-0.1.3-cp38-abi3-macosx_10_12_x86_64.whl:

Publisher: release.yml on marketcalls/wars

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page