Skip to main content

wawm

A developer-first Python client for WhatsApp groups and messaging, driving a number you have linked to your own WAWM deployment (the whatsapp-api-manager server).

Meta's official Cloud API has no group support. This one does, because it drives a real linked device over the WhatsApp Web protocol.

pip install wawm           # add [events] for the live stream
from wawm import WhatsApp

client = WhatsApp(api_key="wam_…", base_url="https://wa.example.com")

client.messages.send("447700900123", "Deploy finished ✅")

team = client.groups.find("Engineering")
client.group(team.id).send("standup in 5")

Initialize once, use anywhere

If passing a client through every layer is not how your app is shaped, initialize once at start-up and reach the same handle from anywhere:

# main.py
import wawm
wawm.initialize()          # reads WAWM_API_KEY and WAWM_BASE_URL

# notifications.py — no plumbing, no imports of your own wiring
import wawm

def alert(text: str) -> None:
    wawm.messages.send("447700900123", text)

wawm.initialize() is idempotent: calling it twice returns the client you already have rather than quietly opening a second connection pool. Pass force=True to replace it.

You never pass a number, either. An API key is bound to exactly one WhatsApp number for life, so the SDK reads it off the key on first use. Nothing to configure, and nothing to get wrong.

Grouped by what you are doing

client.session       is the number connected?
client.messages      send, read, edit, delete, react, pin, forward
client.chats         archive, pin, mute, mark read, typing indicators
client.groups        list, search, create, rename, members, invites, settings
client.contacts      search, validate numbers, block
client.profile       the account's own name, picture and privacy
client.communities   communities
client.channels      channels (broadcast)
client.activity      the durable trail of what happened
client.events        the live stream, over WebSocket
client.inbox         the durable message queue, each message read once

Type client.groups. and your editor lists everything a group can do. The package ships py.typed, so the hints are real.

Groups

client.groups.list()                              # every group, from cache
client.groups.search("support")                   # name substring
client.groups.find("Engineering")                 # exactly one, or LookupError
client.groups.get(gid)                            # full, with participants

client.groups.create("Launch team", ["447700900123", "447700900124"])
client.groups.rename(gid, "Launch team (Q3)")
client.groups.set_description(gid, "Ship by Friday")
client.groups.settings(gid, who_can_post="admins", join_approval=True)

client.groups.invite(gid).url                     # https://chat.whatsapp.com/…
client.groups.revoke_invite(gid)
client.groups.leave(gid)

find() raises rather than guessing when several groups match. Picking the wrong one is how a message reaches the wrong people, and that is not undoable.

A group handle, when you are doing several things at once

team = client.group("120363…@g.us")

team.send("standup in 5")
team.add(["447700900123"])
team.promote(["447700900123"])
team.settings(who_can_post="admins")
print(team.invite().url)

client.chat(id) gives the same thing for a one-to-one conversation.

Partial success is never collapsed

WhatsApp answers membership changes per person, so adding five people can leave three in and two out for different reasons. The result keeps all of it:

result = client.groups.add(gid, ["447700900123", "447700900124"])

print(result)                     # "1 ok, 1 failed"
for entry in result.failed:
    print(entry.jid, entry.reason)   # "participant restricts who can add them to groups"

result.raise_for_failures()        # if partial success should be an error for you

Reporting add() as a single success or failure would be wrong, so the SDK will not let you.

Receiving

There are two ways, and they are different guarantees rather than two flavours of one. Pick by what happens while your process is not running.

client.inbox — a durable queue, each message handled exactly once. Use this for a worker.

for message in client.inbox.listen():
    print(message.sender_name, message.text)
    if message.text == "ping":
        client.chat(message.chat_id).send("pong")

No cursor, and nothing to remember between runs: the server keeps your place. Every incoming message is queued when it arrives and deleted only when you have it, so a backlog built up while you were down is waiting on your next call and survives a server restart. Reading is destructive and first-come-first-served, so two workers on one number split the traffic rather than both seeing all of it. Needs only messages:read — no extras.

Reach for batches() when you want the counters as well as the messages:

for batch in client.inbox.batches():
    if batch.dropped:      # expired (24h) or overflowed the queue before you read them
        backfill(client.messages.list(limit=100))
    if batch.gap:          # offsets that never reached you — a crash, or a second consumer
        alert(batch.gap)
    handle(batch.messages)

There is deliberately no "how many are unread": nothing can tell you what is queued without taking it. remaining and dropped come back with a batch, after the fact.

client.events — the live stream, everything the bus emits. Use this to watch, not to process.

for event in client.events.listen(types=["group.participants"], since=last_seq):
    print(event.seq, event.type, event.data)

Every event carries a monotonic seq, and the stream resumes from the last one it saw after a drop. Know that limit before you build a worker on it: the replay is a bounded in-memory ring on the server that does not survive a restart, and the stream is a broadcast, so two listeners both see everything and neither can tell the other it has handled one. It is the only way to see group changes, receipts and connection state. Needs the events extra and events:subscribe.

Sending media

client.messages.send_media(chat, kind="image", url="https://example.com/chart.png",
                           caption="This week")
client.messages.send_media(chat, kind="document", file="report.pdf")
client.messages.send_poll(chat, "Ship today?", ["Yes", "No", "Needs review"])
client.messages.send_location(chat, 51.5308, -0.1238, name="King's Cross")

A URL is streamed by the server, so large files never pass through your process. URLs must be publicly reachable — private and internal addresses are refused, deliberately.

Before messaging a number you have not messaged before

if client.contacts.exists("447700900123"):
    client.messages.send("447700900123", "hello")

Sending to numbers that are not on WhatsApp is one of the strongest signals used to ban an account. This check is the cheap way to avoid it.

Errors

Every failure raises a typed exception carrying the API's stable code. Branch on the class:

from wawm import NotGroupAdmin, RateLimited, SessionNotConnected

try:
    client.groups.rename(gid, "New name")
except NotGroupAdmin:
    print("this number is not an admin of that group")   # retrying will not help
except SessionNotConnected:
    print("the device is offline; a person has to re-pair it")
except RateLimited as exc:
    time.sleep(exc.retry_after or 30)
Exception When
AuthenticationError key missing, expired or revoked
PermissionDenied the key lacks the permission — .required, .granted
NotGroupAdmin WhatsApp refused; the number is not a group admin
ValidationError / InvalidJID bad arguments; never retry as-is
NotFound / MessageNotFound / SessionNotFound it does not exist
SessionNotConnected / SessionLoggedOut the device is not usable
RateLimited slow down — .retry_after
UpstreamTimeout / ConnectionLost transient; retrying is reasonable
UpstreamError WhatsApp said no; retrying will not change that
MediaTooLarge / MediaFailed / NotSupported media and protocol limits
TransportError the API could not be reached at all

All descend from WhatsAppError, so one except catches everything.

Reads are retried, writes are not. A retried send double-posts, and a retried create leaves an orphan group, so the SDK retries GET only — and only when the server marked the failure retryable. This mirrors what the server does internally rather than second-guessing it.

Permissions

A key carries an explicit permission list, and the SDK can tell you what it holds without a round trip after the first call:

client.can("messages:send")            # True / False
client.require("messages:send", "groups:manage")   # raise now, not mid-batch
print(client.describe())
wawm 0.1.0
  endpoint   https://wa.example.com
  number     whatsapp-biz-hp3y
  credential api-key "notifier"
  access     10 permissions
    client.chats        read, write
    client.groups       read
    client.messages     manage, read, send
    ...

require() at start-up is worth the two lines in a long-running service: better to refuse to boot than to find out halfway through a batch that the key cannot send.

A key can never pair or unlink the device, read the pairing QR, manage API keys, or reach any other number on the deployment — regardless of what it was granted.

Configuration

Argument Environment Default
api_key WAWM_API_KEY — required
base_url WAWM_BASE_URL — required
session WAWM_SESSION discovered from the key
timeout 30s (media calls override)
max_retries 2, reads only
allow_insecure_http False — silences the clear-text warning
trust_env True — honour HTTP_PROXY and SSL_CERT_FILE
client = WhatsApp(timeout=60, max_retries=0)      # both from the environment
with WhatsApp() as client:                        # closes the pool on exit
    ...

Security

The API key is the only secret this package holds, so it is treated like one:

  • It travels in an Authorization header, never in a URL — including for the WebSocket, so it stays out of proxy and access logs.
  • repr() of a client and every exception raised are asserted, in tests, not to contain it.
  • Redirects are never followed; httpx would replay the header to whatever host a redirect names.
  • TLS verification cannot be turned off. Plain http to a non-local host warns.
  • A key with control characters is rejected before use — it would otherwise inject headers — and the error does not echo it back.
  • Every path segment is percent-encoded, so an id cannot escape into another endpoint.
  • Nothing is written to disk, nothing is logged, and no server response is ever evaluated or deserialized beyond json.loads.

One required dependency. Details and reporting in SECURITY.md.

Requirements

Python 3.10+, and a running whatsapp-api-manager deployment with a paired number. Create an API key in the console under a number's API keys tab; the secret is shown once.

Only httpx is required. websockets comes with the events extra.

A word of caution

This drives a real WhatsApp account through an unofficial protocol. Messages reach real people instantly, group changes are visible to every member, and WhatsApp bans numbers that behave like machines. The server rate-limits sends for that reason and the defaults are deliberately timid — raise them knowing what you are trading.

Documentation

docs/ the map
docs/production.md deployment shapes, concurrency, delivery semantics, ban-risk controls, observability, a pre-flight checklist
AGENTS.md the dense reference for AI agents — every signature, the hard rules, and what deliberately does not exist. Ships inside the installed package, readable offline via importlib.resources
SECURITY.md threat model and reporting
CHANGELOG.md what changed

Releasing

See PUBLISHING.md.

License

MIT

Metadata

Release files for wawm 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for wawm 0.1.0
File Size Uploaded
wawm-0.1.0.tar.gz 95.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for wawm 0.1.0
File Interpreter ABI Platform
wawm-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 172.0 kB

Release files / wawm-0.1.0.tar.gz

Download URL wawm-0.1.0.tar.gz
Size 95.9 kB
Tags Source
SHA-256 checksum
How to use checksums
cb14ed1efc5e46a8f8106846867f5e342b052be75a13bffc3893c444a004e616
BLAKE2b-256 checksum
How to use checksums
c28978bb5656f7349115cd382d418ce546cd6bab49404698d589a9ab309d2f9a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 1, 2026.

Transparency log

Release files / wawm-0.1.0-py3-none-any.whl

Download URL wawm-0.1.0-py3-none-any.whl
Size 76.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
a906e37ba3a6205dddb007a957e41512b775c0fc5c8549223c3df4b629215a94
BLAKE2b-256 checksum
How to use checksums
8eba2a783521b58ba9a2905ee43a921f7d3e95369d48edad4807599614ce2487
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 1, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page