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(owner="919876543210")
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. Pass owner= once and single-arg sends go to your own number — perfect for personal alerts.

from wars import WhatsApp

# No db_path → in-memory session. Re-pair if the process restarts.
wa = WhatsApp(owner="14155550100")
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, owner="919876543210")
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, owner="919876543210")
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(owner="919876543210")
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", owner="919876543210")

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", owner="14155550100")

@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", owner=OWNER)
        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, owner=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.
  • owner= — default recipient for single-arg send() calls.

WhatsApp.from_bytes(blob, owner=None)

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.2-cp38-abi3-win_amd64.whl (7.0 MB view details)

Uploaded CPython 3.8+Windows x86-64

wars-0.1.2-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.2-cp38-abi3-manylinux_2_28_aarch64.whl (6.2 MB view details)

Uploaded CPython 3.8+manylinux: glibc 2.28+ ARM64

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

Uploaded CPython 3.8+macOS 11.0+ ARM64

wars-0.1.2-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.2-cp38-abi3-win_amd64.whl.

File metadata

  • Download URL: wars-0.1.2-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.2-cp38-abi3-win_amd64.whl
Algorithm Hash digest
SHA256 518c11b2809d5f2d151b9f7676410439c8a0fa2a7a5216d9cd84aec07b777df9
MD5 63c9e9dca3820b9bc1d54e18649be53a
BLAKE2b-256 be74f18f10971b94432b4c55831680acfcc4a077cb1fbecd58d187c36df72159

See more details on using hashes here.

Provenance

The following attestation bundles were made for wars-0.1.2-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.2-cp38-abi3-manylinux_2_28_x86_64.whl.

File metadata

  • Download URL: wars-0.1.2-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.2-cp38-abi3-manylinux_2_28_x86_64.whl
Algorithm Hash digest
SHA256 c3bf9464f0831af8b8d00bdb05c1c0c595aa03db1d526ddf0c86317c92af370f
MD5 4b9e659502c049cf0894c0784677fa3e
BLAKE2b-256 a1c09ddbea3ce48ff6f5b1766e9566c3876da7a715b2aee33405090d9ab42f19

See more details on using hashes here.

Provenance

The following attestation bundles were made for wars-0.1.2-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.2-cp38-abi3-manylinux_2_28_aarch64.whl.

File metadata

File hashes

Hashes for wars-0.1.2-cp38-abi3-manylinux_2_28_aarch64.whl
Algorithm Hash digest
SHA256 ae110a897eae8ccab4dc7eb02eae84f5872d22329025fdcfd0eb21448d8b21c1
MD5 3dc7253d3b65990ead710f843659ce55
BLAKE2b-256 f18970170073ad2cbcbb0ff93604b2f46ddd2c9d5f31197a01c47e4e9c62d55a

See more details on using hashes here.

Provenance

The following attestation bundles were made for wars-0.1.2-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.2-cp38-abi3-macosx_11_0_arm64.whl.

File metadata

  • Download URL: wars-0.1.2-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.2-cp38-abi3-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 b80d75d30a0de3b93ba416ddaf0e9503641bffcb91aad1f8b1d5d8efa3dc8b74
MD5 e0f80db6e7c8f1d11e8116203aaa9f2a
BLAKE2b-256 2e54744239a3d99fb78e065948d23dfb710a777ed369d36df5af5ea597474d9c

See more details on using hashes here.

Provenance

The following attestation bundles were made for wars-0.1.2-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.2-cp38-abi3-macosx_10_12_x86_64.whl.

File metadata

  • Download URL: wars-0.1.2-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.2-cp38-abi3-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 c65116166b681e1f18797b11ea527e3b96a9047748da38ff1a67f1dd454ca553
MD5 3f0eedf0b3efcc0be36d144100a704c9
BLAKE2b-256 eed5323373cb30d3baf7acae61597aa943aa04038b1c3bbf5d6c5ec34f262535

See more details on using hashes here.

Provenance

The following attestation bundles were made for wars-0.1.2-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