Skip to content
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@

> NOTE: pywa follows the [semver](https://semver.org/) versioning standard.

#### 4.3.0 (2026-08-07) **Latest**
#### 4.3.1 (2026-08-17) **Latest**

- [callback] treat missing message context as no `reply_to_message`
- [types] deprecate `caller` and `callee` properties as part of BSUID changes
- [sent_update] better recipient type resolving
- [enums] adding copy-paste-compatible repr for `UploadedBy` and `UserIdentifier`

#### 4.3.0 (2026-08-07)

- [filters] make `Filter` class generic
- [signups] adding support for creating listing and updating signups
Expand Down
35 changes: 35 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Security Policy

## Supported Versions

Currently, only the **4.x** release line is supported with security updates.

Versions prior to 4.0 are no longer supported due to the `BSUID` changes that introduced breaking changes to the older
codebase. We strongly encourage all users to upgrade to the latest 4.x release.

| Version | Supported |
|---------|--------------------|
| 4.x.x | :white_check_mark: |
| < 4.0.0 | :x: |

## Reporting a Vulnerability

We take the security of **pywa** seriously. If you discover a security vulnerability, please do not disclose it
publicly.

**How to report:**
Please use GitHub's Private Vulnerability Reporting feature to ensure the issue remains confidential until a fix is
released.

1. Go to the **Security** tab of this repository and click **Report a vulnerability**.
2. Provide a clear description of the issue and steps to reproduce it.

Alternatively, you can email reports directly to **david@davidlev.dev**.

**What to expect:**

* You will receive an acknowledgment of your report within 48 hours.
* We will use the private advisory to communicate, verify the issue, and work on a patch.
* Once the vulnerability is patched and published, you will be credited for the discovery.

Please avoid opening public GitHub issues for sensitive security matters.
2 changes: 1 addition & 1 deletion pywa/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@
from pywa.client import WhatsApp
from pywa.utils import Version

__version__ = "4.3.0"
__version__ = "4.3.1"
__author__ = "David Lev"
__license__ = "MIT"
6 changes: 3 additions & 3 deletions pywa/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,13 +287,13 @@ def _register_routes(self: "WhatsApp") -> None:

match self._server_type:
case utils.CustomServerType.STARLETTE:
_logger.info("Using Starlette")
_logger.debug("Using Starlette")
helpers.register_routes_starlette(wa=self)
case utils.CustomServerType.FASTAPI:
_logger.info("Using FastAPI")
_logger.debug("Using FastAPI")
helpers.register_routes_fastapi(wa=self)
case utils.CustomServerType.FLASK:
_logger.info("Using Flask")
_logger.debug("Using Flask")
helpers.register_routes_flask(wa=self)
case _:
raise ValueError(
Expand Down
8 changes: 4 additions & 4 deletions pywa/types/base_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -1315,9 +1315,9 @@ class BaseUserUpdate(BaseUpdate, _ClientShortcuts, abc.ABC):
"""The WhatsApp Business Account ID that the update was sent to."""

@property
def sender(self) -> str | None:
def sender(self) -> None:
"""
Deprecated. Use :py:attr:`~pywa.types.others.User.wa_id` instead.
Deprecated. Use ``update.from_user.wa_id`` instead.
"""
warnings.warn(
"Deprecated. Use `update.from_user.wa_id` instead.",
Expand All @@ -1327,9 +1327,9 @@ def sender(self) -> str | None:
return self.from_user.wa_id

@property
def recipient(self) -> str:
def recipient(self) -> None:
"""
Deprecated. Use :py:attr:`~pywa.types.others.Metadata.phone_number_id` instead.
Deprecated. Use ``update.metadata.phone_number_id`` instead.
"""
warnings.warn(
"Deprecated. Use `update.metadata.phone_number_id` instead.",
Expand Down
9 changes: 6 additions & 3 deletions pywa/types/callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,14 +258,14 @@ class CallbackButton(BaseUserUpdate, Generic[_CallbackDataT]):
type: The message type (:class:`MessageType.INTERACTIVE` for :class:`~pywa.types.callback.Button` presses or :class:`MessageType.BUTTON` for :class:`~pywa.types.templates.QuickReplyButton` clicks).
from_user: The user who sent the message.
timestamp: The timestamp when the message was sent (in UTC).
reply_to_message: The message to which this callback button is a reply to.
reply_to_message: The message to which this callback button is a reply to. ``None`` when a template :class:`~pywa.types.templates.QuickReplyButton` is clicked (WhatsApp omits the ``context`` field for template ``button`` messages).
data: The data of the button (the ``callback_data`` parameter you provided in :class:`~pywa.types.callback.Button` or :class:`~pywa.types.templates.QuickReplyButton`).
title: The title of the button.
shared_data: Shared data between handlers.
"""

type: MessageType
reply_to_message: ReplyToMessage
reply_to_message: ReplyToMessage | None
data: _CallbackDataT
title: str

Expand All @@ -286,6 +286,7 @@ def from_update(cls, client: "WhatsApp", update: RawUpdate) -> "CallbackButton":
data = msg["button"]["payload"]
case _:
raise ValueError(f"Invalid message type {msg_type}")
context = msg.get("context", {})
return cls(
_client=client,
raw=update,
Expand All @@ -295,7 +296,9 @@ def from_update(cls, client: "WhatsApp", update: RawUpdate) -> "CallbackButton":
type=MessageType(msg_type),
from_user=client._usr_cls.from_contact(value["contacts"][0], client=client),
timestamp=helpers.timestamp_to_datetime(msg["timestamp"]),
reply_to_message=ReplyToMessage.from_dict(msg["context"]),
reply_to_message=ReplyToMessage.from_dict(context)
if context.get("id")
else None,
data=data,
title=title,
)
Expand Down
34 changes: 25 additions & 9 deletions pywa/types/calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,11 @@

import dataclasses
import datetime
import warnings
from typing import TYPE_CHECKING, Generic

from .. import _helpers as helpers
from ..errors import WhatsAppError
from ..errors import PywaDeprecationWarning, WhatsAppError
from .base_update import BaseUpdate, BaseUserUpdate, RawUpdate, _ClientShortcuts # noqa
from .callback import CallbackData, _CallbackDataT
from .others import (
Expand All @@ -67,25 +68,40 @@ class _CallShortcuts:

id: str
_client: WhatsApp
_internal_sender: str
_internal_recipient: str

@property
def message_id_to_reply(self) -> str:
"""Raises an error because call terminate updates cannot be replied."""
raise ValueError("You cannot use `message_id_to_reply` to quote a call update.")

@property
def caller(self) -> str:
def caller(self) -> None:
"""
The WhatsApp ID of the business phone number that initiated the call.
Deprecated. Use ``call.from_user.wa_id`` instead.
"""
return self._internal_recipient
warnings.warn(
"Deprecated. Use `call.chat.id` instead.",
PywaDeprecationWarning,
stacklevel=2,
)
return (
self.from_user.wa_id if hasattr(self, "from_user") else self.from_phone_id # ty:ignore[unresolved-attribute]
)

@property
def callee(self) -> str:
"""The WhatsApp ID of the user that received the call."""
return self._internal_sender
def callee(self) -> None:
"""
Deprecated. Use ``call.metadata.phone_number_id`` instead.
"""
warnings.warn(
"Deprecated. Use `call.metadata.phone_number_id` instead.",
PywaDeprecationWarning,
stacklevel=2,
)

return (
self.metadata.phone_number_id if hasattr(self, "metadata") else self.chat.id # ty:ignore[unresolved-attribute]
)

def pre_accept(self, *, sdp: SessionDescription) -> SuccessResult:
"""
Expand Down
3 changes: 3 additions & 0 deletions pywa/types/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ class UploadedBy(enum.Enum):
BUSINESS = BUSINESS_UPLOADS_EXPIRATION_DAYS
USER = USER_UPLOADS_EXPIRATION_DAYS

def __repr__(self) -> str:
return f"UploadedBy.{self.name}"


class _MediaActions:
_client: WhatsApp
Expand Down
20 changes: 10 additions & 10 deletions pywa/types/sent_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,12 @@ def from_recipient(cls, recipient: str | int) -> RecipientType:
if _BSUID_RE.fullmatch(s):
return cls.BSUID

if re.sub(_PHONE_CLEAN_RE, "", s).isdigit():
return RecipientType.PHONE_NUMBER

if re.match(_PARENT_BSUID_RE, s):
if _PARENT_BSUID_RE.fullmatch(s):
return cls.PARENT_BSUID

if _PHONE_CLEAN_RE.sub("", s).isdigit():
return cls.PHONE_NUMBER

return cls.GROUP_ID

def to_chat_type(self) -> ChatType:
Expand Down Expand Up @@ -171,24 +171,24 @@ class SentMessage(_SentUpdate, _PinUnpinActions):
)

@property
def recipient(self) -> str:
"""Deprecated. Use :py:attr:`~pywa.types.SentMessage.chat.id` / :py:attr:`~pywa.types.SentMessage.to` instead."""
def recipient(self) -> None:
"""Deprecated. Use ``sent.chat.id`` or ``sent.to`` instead."""
warnings.warn(
"Deprecated. Use `sent.chat.id` or `sent.to` instead.",
PywaDeprecationWarning,
stacklevel=2,
)
return self._internal_sender
return self.chat.id

@property
def sender(self) -> str:
"""Deprecated. Use :py:attr:`~pywa.types.SentMessage.from_phone_id` instead."""
def sender(self) -> None:
"""Deprecated. Use ``sent.from_phone_id`` instead."""
warnings.warn(
"Deprecated. Use `sent.from_phone_id` instead.",
PywaDeprecationWarning,
stacklevel=2,
)
return self._internal_recipient
return self.from_phone_id

@classmethod
def from_sent_update(
Expand Down
3 changes: 3 additions & 0 deletions pywa/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,9 @@ class UserIdentifier(enum.Enum):
def user_attr(self) -> str:
return self.value

def __repr__(self) -> str:
return f"UserIdentifier.{self.name}"


def start_ngrok_tunnel(
*,
Expand Down
2 changes: 1 addition & 1 deletion pywa_async/types/callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ class CallbackButton(BaseUserUpdateAsync, _CallbackButton[_CallbackDataT]):
type: The message type (:class:`MessageType.INTERACTIVE` for :class:`~pywa.types.callback.Button` presses or :class:`MessageType.BUTTON` for :class:`~pywa.types.templates.QuickReplyButton` clicks).
from_user: The user who sent the message.
timestamp: The timestamp when the message was sent (in UTC).
reply_to_message: The message to which this callback button is a reply to.
reply_to_message: The message to which this callback button is a reply to. ``None`` when a template :class:`~pywa.types.templates.QuickReplyButton` is clicked (WhatsApp omits the ``context`` field for template ``button`` messages).
data: The data of the button (the ``callback_data`` parameter you provided in :class:`~pywa.types.callback.Button` or :class:`~pywa.types.templates.QuickReplyButton`).
title: The title of the button.
shared_data: Shared data between handlers.
Expand Down
43 changes: 43 additions & 0 deletions tests/data/updates/callback_button.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,5 +93,48 @@
]
}
]
},
"quick_reply_without_context": {
"object": "whatsapp_business_account",
"entry": [
{
"id": "5467539754836534",
"changes": [
{
"value": {
"messaging_product": "whatsapp",
"metadata": {
"display_phone_number": "972123456789",
"phone_number_id": "1122334455667"
},
"contacts": [
{
"profile": {
"name": "Test Name"
},
"wa_id": "972987654321",
"user_id": "US.13491208655302741918",
"parent_user_id": "US.ENT.11815799212886844830"
}
],
"messages": [
{
"from": "972987654321",
"from_user_id": "US.13491208655302741918",
"id": "wamid.xsidjx",
"timestamp": "1698269027",
"type": "button",
"button": {
"payload": "callback_data",
"text": "title"
}
}
]
},
"field": "messages"
}
]
}
]
}
}
4 changes: 2 additions & 2 deletions tests/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,10 +312,10 @@ def test_initiated_call():
recipient_type=RecipientType.WA_ID,
callee="16506666666",
)
assert c.caller == wa.phone_id
assert c.from_phone_id == wa.phone_id
assert c.chat.id == "16506666666"
assert c.to == "16506666666"
assert c.callee == "16506666666"
assert c.chat.id == "16506666666"
assert c == InitiatedCall(
_client=wa,
_recipient_type=RecipientType.WA_ID,
Expand Down
14 changes: 12 additions & 2 deletions tests/test_updates.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,18 @@
"media_with_url": [lambda m: m.media.url is not None],
},
"callback_button": {
"button": [lambda b: b.type == MessageType.INTERACTIVE],
"quick_reply": [lambda b: b.type == MessageType.BUTTON],
"button": [
lambda b: b.type == MessageType.INTERACTIVE,
lambda b: b.reply_to_message is not None,
],
"quick_reply": [
lambda b: b.type == MessageType.BUTTON,
lambda b: b.reply_to_message is not None,
],
"quick_reply_without_context": [
lambda b: b.type == MessageType.BUTTON,
lambda b: b.reply_to_message is None,
],
},
"callback_selection": {
"callback": [lambda s: s.data is not None],
Expand Down