diff --git a/README.md b/README.md
index b962cd4..a728239 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,10 @@
- [Managing chatbot (automated responses) status settings](#bot-on-off)
- [Get chatbot (automated responses) status](#bot-status)
+#### Webhooks
+
+- [Subscribe to webhook](#subscribe-webhook)
+
#### Mics
- [Pagination](#pagination)
@@ -526,6 +530,59 @@ response.chatbot_active # True/False
```
+## Webhooks
+
+### Subscribe to webhook
+
+Register a URL of yours and subscribe it to a webhook event, so we start sending
+you requests when that event happens. This does the same thing as activating a
+webhook URL in the Sendbee Dashboard.
+
+```python
+webhook = api.subscribe_webhook(
+ url='https://example.com/sendbee/webhook',
+ event='message.received'
+)
+
+webhook.id # UUID of the webhook endpoint
+webhook.url # the URL you registered
+webhook.active # True/False
+webhook.selected_webhooks # list of all events this URL is subscribed to
+webhook.secret_key # key used to sign requests we send to your URL
+webhook.created_at
+```
+
+Both `url` and `event` are required. Calling it again for the same `url` is safe -
+it will not create a duplicate, it just adds the new event to that URL (and
+re-activates it if it was deactivated), so subscribe one event per call:
+
+```python
+for event in ('message.received', 'message.sent'):
+ api.subscribe_webhook(url='https://example.com/sendbee/webhook', event=event)
+```
+
+Available events:
+
+| Event | Description |
+| --- | --- |
+| `contact.created` | A new contact was created |
+| `contact.subscription_created` | A contact subscribed |
+| `contact.subscription_updated` | A contact's subscription changed |
+| `contact.unsubscribed` | A contact unsubscribed |
+| `message.received` | A message was received from a contact |
+| `message.sent` | A message was sent to a contact |
+| `message.sent_status` | Delivery status of a sent message changed |
+| `whatsapp.message.session.sent_status` | Delivery status of a session message changed |
+| `whatsapp.message.template.sent_status` | Delivery status of a template message changed |
+| `whatsapp.message.template.approval_status` | A message template was approved or rejected |
+| `conversation.change_folder` | A conversation was moved to another folder |
+
+An unknown event name returns a `SendbeeRequestApiException`. For the authoritative
+and up to date list, see the [documentation](https://developer.ainumber.com/#webhooks).
+
+Once subscribed, see [Authenticate webhook request](#authenticate-webhook-request)
+for how to verify the requests we send you.
+
## Misc
### Pagination
@@ -611,7 +668,7 @@ except SendbeeRequestApiException as e:
### Authenticate webhook request
-After activating your webhook URL in Sendbee Dashboard, we will start sending requests on that URL depending on which webhook type is linked with that webhook URL.
+After activating your webhook URL in Sendbee Dashboard or with [Subscribe to webhook](#subscribe-webhook), we will start sending requests on that URL depending on which webhook type is linked with that webhook URL.
Every request that we make will have authorization token in header, like this:
```
@@ -634,6 +691,21 @@ if not api.auth.check_auth_token(token):
# error! authentication failed!
```
+Requests to a webhook URL that has its own secret key also carry a second header,
+`X-Auth-Token`, signed with that key instead of your account secret. You get the
+key back as `webhook.secret_key` from [Subscribe to webhook](#subscribe-webhook).
+Check it the same way, using `SendbeeAuth` directly:
+
+```python
+from sendbee_api import SendbeeAuth
+
+secret_key = '...' # webhook.secret_key, store it when you subscribe
+
+token = '...' # taken from the 'X-Auth-Token' request header
+if not SendbeeAuth(secret_key).check_auth_token(token):
+ # error! authentication failed!
+```
+
### Warnings
Sometimes APi returns a warning so you could be warned about something.
diff --git a/pyproject.toml b/pyproject.toml
index 2e7783f..00a21e8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "sendbee_api"
-version = "1.7.2"
+version = "1.8.0"
description = "Python client SDK for Sendbee Public API"
readme = "README.md"
requires-python = ">=3.6"
diff --git a/sendbee_api/client.py b/sendbee_api/client.py
index 9a2e7a0..864b211 100644
--- a/sendbee_api/client.py
+++ b/sendbee_api/client.py
@@ -3,13 +3,14 @@
from sendbee_api.auth import SendbeeAuth
from sendbee_api.teams.client import Teams
from sendbee_api.contacts.client import Contacts
+from sendbee_api.webhooks.client import Webhooks
from sendbee_api.rate_limit.client import RateLimit
from sendbee_api.automation.client import Automation
from sendbee_api.conversations.client import Messages
from sendbee_api.exceptions import SendbeeRequestApiException
-class Client(Contacts, Messages, Automation, Teams, RateLimit):
+class Client(Contacts, Messages, Automation, Teams, Webhooks, RateLimit):
"""Main API class. Sets all API calls."""
base_url = 'api-v2.sendbee.io'
diff --git a/sendbee_api/webhooks/__init__.py b/sendbee_api/webhooks/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/sendbee_api/webhooks/client.py b/sendbee_api/webhooks/client.py
new file mode 100644
index 0000000..905c078
--- /dev/null
+++ b/sendbee_api/webhooks/client.py
@@ -0,0 +1,16 @@
+from sendbee_api import constants
+from sendbee_api.webhooks import models
+from sendbee_api.bind import bind_request
+from sendbee_api.webhooks import query_params
+
+
+class Webhooks:
+ """Api client for webhooks"""
+
+ subscribe_webhook = bind_request(
+ api_path='/webhooks/subscribe',
+ model=models.Webhook,
+ method=constants.RequestConst.POST,
+ query_parameters=query_params.SubscribeWebhook,
+ description='Api client for subscribing to a webhook event'
+ )
diff --git a/sendbee_api/webhooks/models.py b/sendbee_api/webhooks/models.py
new file mode 100644
index 0000000..e7c07ea
--- /dev/null
+++ b/sendbee_api/webhooks/models.py
@@ -0,0 +1,22 @@
+from sendbee_api.models import Model
+from sendbee_api.fields import (
+ TextField, BooleanField, DatetimeField, ListField
+)
+
+
+class Webhook(Model):
+ """Data model for a webhook endpoint subscription"""
+
+ _id = TextField(index='id', desc='UUID')
+ _url = TextField(index='url', desc='Webhook endpoint URL')
+ _active = BooleanField(index='active', desc='Is the endpoint active')
+ _secret_key = TextField(
+ index='secret_key',
+ desc='Key that signs the X-Auth-Token header on inbound webhooks'
+ )
+ _selected_webhooks = ListField(
+ index='selected_webhooks', desc='Subscribed webhook event names'
+ )
+ _created_at = DatetimeField(
+ index='created_at', desc='Created at', format='%Y-%m-%d %H:%M:%'
+ )
diff --git a/sendbee_api/webhooks/query_params.py b/sendbee_api/webhooks/query_params.py
new file mode 100644
index 0000000..3cd0f59
--- /dev/null
+++ b/sendbee_api/webhooks/query_params.py
@@ -0,0 +1,9 @@
+from sendbee_api.query_params import QueryParams
+
+
+class SubscribeWebhook(QueryParams):
+ """Parameters for subscribing to a webhook event"""
+
+ url = 'url', 'Webhook endpoint URL that will receive the events'
+ event = 'event', 'Webhook event name, e.g. "message.received". ' \
+ 'See https://developer.ainumber.com/#webhooks'
diff --git a/tests/test_endpoints_smoke.py b/tests/test_endpoints_smoke.py
index 7acad8b..eef6bb1 100644
--- a/tests/test_endpoints_smoke.py
+++ b/tests/test_endpoints_smoke.py
@@ -1,6 +1,6 @@
"""End-to-end smoke tests: one happy-path per resource mixin.
-The 24 endpoint methods on `SendbeeApi` are all generated by `bind_request`,
+The 25 endpoint methods on `SendbeeApi` are all generated by `bind_request`,
which is covered in detail elsewhere. These tests verify the wiring per mixin
- that each `bind_request(...)` call site declares the right `api_path`,
`method`, `model`, and `QueryParams` - by exercising one representative
@@ -14,6 +14,7 @@
from sendbee_api.conversations.models import Conversation, SentMessage
from sendbee_api.automation.models import ChatbotActivityStatus
from sendbee_api.teams.models import Team
+from sendbee_api.webhooks.models import Webhook
from sendbee_api.rate_limit.models import RateLimitError
from sendbee_api.models import ServerMessage
@@ -125,6 +126,43 @@ def test_teams_list_endpoint_returns_team_models(client, register, json_body):
assert response.models[0].name == "Support"
+@responses.activate
+def test_subscribe_webhook_endpoint_returns_webhook_model(client, register, json_body):
+ """subscribe_webhook POSTs to /webhooks/subscribe and parses one Webhook.
+
+ The body is a bare object rather than a list because the backend's
+ PaginationJsonRenderer emits {"data": {...}} with no meta for non-GET
+ requests; Response.models wraps that lone dict before processing it.
+ """
+ register("POST", "/webhooks/subscribe", body=json_body(
+ data={
+ "id": "wh1",
+ "active": True,
+ "url": "https://example.com/hook",
+ "secret_key": "s3cr3t",
+ "selected_webhooks": ["message.received"],
+ "created_at": "2026-07-27 10:11:12",
+ },
+ ))
+
+ result = client.subscribe_webhook(
+ url="https://example.com/hook", event="message.received"
+ )
+
+ assert responses.calls[0].request.method == "POST"
+ assert "/webhooks/subscribe" in responses.calls[0].request.url
+
+ body = ujson.loads(responses.calls[0].request.body)
+ assert body == {"url": "https://example.com/hook", "event": "message.received"}
+
+ assert isinstance(result, Webhook)
+ assert result.id == "wh1"
+ assert result.active is True
+ assert result.url == "https://example.com/hook"
+ assert result.secret_key == "s3cr3t"
+ assert result.selected_webhooks == ["message.received"]
+
+
@responses.activate
def test_rate_limit_error_test_endpoint_returns_rate_limit_error_model(client, register, json_body):
"""rate_limit_error_test hits /rate-limit/error-test on the RateLimit mixin."""