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
Authorizationheader, 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
httpto 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)
| File | Size | Uploaded | |
|---|---|---|---|
| wawm-0.1.0.tar.gz | 95.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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