Skip to content

Commit 11ae975

Browse files
committed
DEVEXP-795: Conversation Webhooks
1 parent afdcd17 commit 11ae975

18 files changed

Lines changed: 1113 additions & 6 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ jobs:
101101
cp sinch-sdk-mockserver/features/sms/webhooks.feature ./tests/e2e/sms/features/
102102
cp sinch-sdk-mockserver/features/number-lookup/lookups.feature ./tests/e2e/number-lookup/features/
103103
cp sinch-sdk-mockserver/features/conversation/messages.feature ./tests/e2e/conversation/features/
104+
cp sinch-sdk-mockserver/features/conversation/webhooks-events.feature ./tests/e2e/conversation/features/
104105
105106
- name: Wait for mock server
106107
run: .github/scripts/wait-for-mockserver.sh

examples/webhooks/.env.example

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,6 @@ SERVER_PORT =
66
# See https://developers.sinch.com/docs/numbers/api-reference/numbers/tag/Numbers-Callbacks/
77
NUMBERS_WEBHOOKS_SECRET = NUMBERS_WEBHOOKS_SECRET
88
# See https://developers.sinch.com/docs/sms/api-reference/sms/tag/Webhooks/#tag/Webhooks/section/Callbacks
9-
SMS_WEBHOOKS_SECRET = SMS_WEBHOOKS_SECRET
9+
SMS_WEBHOOKS_SECRET = SMS_WEBHOOKS_SECRET
10+
# See https://developers.sinch.com/docs/conversation/callbacks
11+
CONVERSATION_WEBHOOKS_SECRET = CONVERSATION_WEBHOOKS_SECRET

examples/webhooks/README.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ to process incoming webhooks from Sinch services.
66
The webhook handlers are organized by service:
77
- **SMS**: Handlers for SMS webhook events (`sms_api/`)
88
- **Numbers**: Handlers for Numbers API webhook events (`numbers_api/`)
9+
- **Conversation**: Handlers for Conversation API webhook events (`conversation_api/`)
910

1011
This directory contains both the webhook handlers and the server application (`server.py`) that uses them.
1112

@@ -39,6 +40,10 @@ This directory contains both the webhook handlers and the server application (`s
3940
```
4041
SMS_WEBHOOKS_SECRET=Your Sinch SMS Webhook Secret
4142
```
43+
- Conversation controller: Set the webhook secret you configured when creating the webhook (see [Conversation API callbacks](https://developers.sinch.com/docs/conversation/callbacks)):
44+
```
45+
CONVERSATION_WEBHOOKS_SECRET=Your Conversation Webhook Secret
46+
```
4247

4348
## Usage
4449

@@ -69,10 +74,11 @@ The server will start on the port specified in your `.env` file (default: 3001).
6974

7075
The server exposes the following endpoints:
7176

72-
| Service | Endpoint |
73-
|--------------|--------------------|
74-
| Numbers | /NumbersEvent |
75-
| SMS | /SmsEvent |
77+
| Service | Endpoint |
78+
|--------------|----------------------|
79+
| Numbers | /NumbersEvent |
80+
| SMS | /SmsEvent |
81+
| Conversation | /ConversationEvent |
7682

7783
## Using ngrok to expose your local server
7884

@@ -93,10 +99,12 @@ Forwarding https://adbd-79-148-170-158.ngrok-free.app -> http
9399
Use the `https` forwarding URL in your callback configuration. For example:
94100
- Numbers: https://adbd-79-148-170-158.ngrok-free.app/NumbersEvent
95101
- SMS: https://adbd-79-148-170-158.ngrok-free.app/SmsEvent
102+
- Conversation: https://adbd-79-148-170-158.ngrok-free.app/ConversationEvent
96103

97104
Use this value to configure the callback URLs:
98105
- **Numbers**: Set the `callback_url` parameter when renting or updating a number via the SDK (e.g., `available_numbers_apis` rent/update flow: [rent](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/numbers/api/v1/available_numbers_apis.py#L69), [update](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/numbers/api/v1/available_numbers_apis.py#L89)); you can also update active numbers via `active_numbers_apis` ([example](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/numbers/api/v1/active_numbers_apis.py#L64)).
99-
- **SMS**: Set the `callback_url` parameter when configuring your SMS service plan via the SDK (see `batches_apis` examples: [send/dry-run callbacks](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/sms/api/v1/batches_apis.py#L147), [update/replace callbacks](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/sms/api/v1/batches_apis.py#L491)); you can also set it directly via the SMS API.
106+
- **SMS**: Set the `callback_url` parameter when configuring your SMS service plan via the SDK (see `batches_apis` examples: [send/dry-run callbacks](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/sms/api/v1/batches_apis.py#L146), [update/replace callbacks](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/sms/api/v1/batches_apis.py#L491)); you can also set it directly via the SMS API.
107+
- **Conversation**: Set the `callback_url` parameter when sending a message via the SDK (see `messages_apis` example: [send_text_message](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/conversation/api/v1/messages_apis.py#L420)).
100108

101109
You can also set these callback URLs in the Sinch dashboard; the API parameters above override the default values configured there.
102110

examples/webhooks/conversation_api/__init__.py

Whitespace-only changes.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from flask import request, Response
2+
from webhooks.conversation_api.server_business_logic import handle_conversation_event
3+
4+
5+
class ConversationController:
6+
def __init__(self, sinch_client, webhooks_secret):
7+
self.sinch_client = sinch_client
8+
self.webhooks_secret = webhooks_secret
9+
self.logger = self.sinch_client.configuration.logger
10+
11+
def conversation_event(self):
12+
headers = dict(request.headers)
13+
body_str = request.raw_body.decode("utf-8") if request.raw_body else ""
14+
15+
webhooks_service = self.sinch_client.conversation.webhooks(self.webhooks_secret)
16+
17+
# Set to True to enforce signature validation (recommended in production)
18+
ensure_valid_signature = False
19+
if ensure_valid_signature:
20+
valid = webhooks_service.validate_authentication_header(
21+
headers=headers,
22+
json_payload=body_str,
23+
)
24+
if not valid:
25+
return Response(status=401)
26+
27+
event = webhooks_service.parse_event(body_str)
28+
handle_conversation_event(event=event, logger=self.logger)
29+
30+
return Response(status=200)
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
from sinch.domains.conversation.webhooks.v1.events import (
2+
ConversationWebhookEventBase,
3+
MessageDeliveryReceiptEvent,
4+
MessageInboundEvent,
5+
MessageSubmitEvent,
6+
)
7+
8+
9+
def handle_conversation_event(event: ConversationWebhookEventBase, logger):
10+
"""
11+
Dispatch a Conversation webhook event to the appropriate handler by trigger type.
12+
13+
:param event: Parsed webhook event (MessageDeliveryReceiptEvent, MessageInboundEvent, etc.).
14+
:param logger: Logger instance for output.
15+
"""
16+
if isinstance(event, MessageInboundEvent):
17+
_handle_message_inbound(event, logger)
18+
elif isinstance(event, MessageDeliveryReceiptEvent):
19+
_handle_message_delivery(event, logger)
20+
elif isinstance(event, MessageSubmitEvent):
21+
_handle_message_submit(event, logger)
22+
else:
23+
logger.info("Conversation webhook: unknown or unhandled trigger %s", getattr(event, "trigger", None))
24+
logger.debug("Event: %s", event.model_dump_json(indent=2) if hasattr(event, "model_dump_json") else event)
25+
26+
27+
def _handle_message_inbound(event: MessageInboundEvent, logger):
28+
"""Handle MESSAGE_INBOUND: log inbound message."""
29+
logger.info("## MESSAGE_INBOUND")
30+
if not event.message:
31+
logger.warning("MESSAGE_INBOUND event has no message")
32+
return
33+
msg = event.message
34+
contact_msg = msg.contact_message
35+
channel_identity = msg.channel_identity
36+
contact_id = msg.contact_id
37+
channel = channel_identity.channel if channel_identity else "?"
38+
identity = channel_identity.identity if channel_identity else "?"
39+
logger.info(
40+
"A new message has been received on the channel '%s' (identity: %s) from the contact ID '%s'",
41+
channel,
42+
identity,
43+
contact_id,
44+
)
45+
if contact_msg:
46+
if hasattr(contact_msg, "text_message") and contact_msg.text_message:
47+
logger.info("Text: %s", contact_msg.text_message.text)
48+
elif hasattr(contact_msg, "media_message") and contact_msg.media_message:
49+
logger.info("Media: %s", getattr(contact_msg.media_message, "url", contact_msg.media_message))
50+
elif hasattr(contact_msg, "fallback_message") and contact_msg.fallback_message:
51+
logger.info("Fallback: %s", contact_msg.fallback_message)
52+
else:
53+
logger.info("Contact message: %s", contact_msg)
54+
55+
56+
def _handle_message_delivery(event: MessageDeliveryReceiptEvent, logger):
57+
"""Handle MESSAGE_DELIVERY: log delivery status and failure reason if failed."""
58+
logger.info("## MESSAGE_DELIVERY")
59+
report = event.message_delivery_report
60+
if not report:
61+
logger.warning("MESSAGE_DELIVERY event has no message_delivery_report")
62+
return
63+
status = report.status
64+
logger.info("Message delivery status: '%s'", status)
65+
if status == "FAILED" and report.reason:
66+
logger.info(
67+
"Reason: %s (%s) - %s",
68+
report.reason.code,
69+
getattr(report.reason, "sub_code", ""),
70+
report.reason.description,
71+
)
72+
73+
74+
def _handle_message_submit(event: MessageSubmitEvent, logger):
75+
"""Handle MESSAGE_SUBMIT: log that the message was submitted to the channel."""
76+
logger.info("## MESSAGE_SUBMIT")
77+
notif = event.message_submit_notification
78+
if not notif:
79+
logger.warning("MESSAGE_SUBMIT event has no message_submit_notification")
80+
return
81+
channel_identity = notif.channel_identity
82+
channel = channel_identity.channel if channel_identity else "?"
83+
identity = channel_identity.identity if channel_identity else "?"
84+
logger.info(
85+
"The following message has been submitted on the channel '%s' (identity: %s) to the contact ID '%s'",
86+
channel,
87+
identity,
88+
notif.contact_id,
89+
)
90+
if notif.submitted_message:
91+
logger.debug("Submitted message: %s", notif.submitted_message)

examples/webhooks/server.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from flask import Flask, request
1111
from webhooks.numbers_api.controller import NumbersController
1212
from webhooks.sms_api.controller import SmsController
13+
from webhooks.conversation_api.controller import ConversationController
1314
from webhooks.sinch_client_helper import get_sinch_client, load_config
1415

1516
app = Flask(__name__)
@@ -18,6 +19,7 @@
1819
port = int(config.get('SERVER_PORT') or 3001)
1920
numbers_webhooks_secret = config.get('NUMBERS_WEBHOOKS_SECRET')
2021
sms_webhooks_secret = config.get('SMS_WEBHOOKS_SECRET')
22+
conversation_webhooks_secret = config.get('CONVERSATION_WEBHOOKS_SECRET')
2123
sinch_client = get_sinch_client(config)
2224

2325
# Set up logging at the INFO level
@@ -26,6 +28,7 @@
2628

2729
numbers_controller = NumbersController(sinch_client, numbers_webhooks_secret)
2830
sms_controller = SmsController(sinch_client, sms_webhooks_secret)
31+
conversation_controller = ConversationController(sinch_client, conversation_webhooks_secret or '')
2932

3033

3134
# Middleware to capture raw body
@@ -36,6 +39,7 @@ def before_request():
3639

3740
app.add_url_rule('/NumbersEvent', methods=['POST'], view_func=numbers_controller.numbers_event)
3841
app.add_url_rule('/SmsEvent', methods=['POST'], view_func=sms_controller.sms_event)
42+
app.add_url_rule('/ConversationEvent', methods=['POST'], view_func=conversation_controller.conversation_event)
3943

4044
if __name__ == '__main__':
4145
app.run(port=port)

sinch/domains/conversation/conversation.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from sinch.domains.conversation.api.v1 import (
22
Messages,
33
)
4+
from sinch.domains.conversation.webhooks.v1 import ConversationWebhooks
45

56

67
class Conversation:
@@ -12,3 +13,14 @@ class Conversation:
1213
def __init__(self, sinch):
1314
self._sinch = sinch
1415
self.messages = Messages(self._sinch)
16+
17+
def webhooks(self, callback_secret: str) -> ConversationWebhooks:
18+
"""
19+
Create a Conversation API webhooks handler with the given webhook secret.
20+
21+
:param callback_secret: Secret used for webhook signature validation.
22+
:type callback_secret: str
23+
:returns: A configured webhooks handler.
24+
:rtype: ConversationWebhooks
25+
"""
26+
return ConversationWebhooks(callback_secret)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from sinch.domains.conversation.webhooks.v1.conversation_webhooks import (
2+
ConversationWebhooks,
3+
)
4+
5+
__all__ = ["ConversationWebhooks"]
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
from typing import Any, Dict, Union, Optional
2+
from sinch.domains.authentication.webhooks.v1.authentication_validation import (
3+
validate_webhook_signature_with_nonce,
4+
)
5+
from sinch.domains.authentication.webhooks.v1.webhook_utils import (
6+
parse_json,
7+
normalize_iso_timestamp,
8+
)
9+
from sinch.domains.conversation.webhooks.v1.events import (
10+
ConversationWebhookEventBase,
11+
MessageDeliveryReceiptEvent,
12+
MessageInboundEvent,
13+
MessageSubmitEvent,
14+
)
15+
16+
17+
ConversationWebhookCallback = Union[
18+
MessageDeliveryReceiptEvent,
19+
MessageInboundEvent,
20+
MessageSubmitEvent,
21+
ConversationWebhookEventBase,
22+
]
23+
24+
25+
class ConversationWebhooks:
26+
"""
27+
Handler for Conversation API webhooks: validate signature and parse events.
28+
"""
29+
30+
def __init__(self, webhook_secret: Optional[str] = None):
31+
"""
32+
:param webhook_secret: Secret configured for the webhook (used for HMAC validation).
33+
"""
34+
self.webhook_secret = webhook_secret
35+
36+
def validate_signature(
37+
self,
38+
payload: Union[str, bytes],
39+
headers: Dict[str, str],
40+
webhook_secret: Optional[str] = None,
41+
) -> bool:
42+
"""
43+
Validate the webhook signature using the request body and headers.
44+
45+
Uses x-sinch-webhook-signature, x-sinch-webhook-signature-nonce, and
46+
x-sinch-webhook-signature-timestamp. Returns True only if the signature
47+
is valid.
48+
49+
:param payload: Raw request body (string or bytes).
50+
:param headers: Incoming request headers (key case is normalized to lower).
51+
:param webhook_secret: Secret for this webhook; defaults to the secret passed to __init__.
52+
:returns: True if the signature is valid, False otherwise.
53+
"""
54+
secret = (
55+
webhook_secret
56+
if webhook_secret is not None
57+
else self.webhook_secret
58+
)
59+
if not secret:
60+
return False
61+
if isinstance(payload, bytes):
62+
payload = payload.decode("utf-8")
63+
return validate_webhook_signature_with_nonce(secret, headers, payload)
64+
65+
def validate_authentication_header(
66+
self, headers: Dict[str, str], json_payload: str
67+
) -> bool:
68+
"""
69+
Validate the webhook signature (convenience alias for validate_signature).
70+
71+
:param headers: Incoming request's headers.
72+
:param json_payload: Incoming request's raw body.
73+
:returns: True if the X-Sinch-Webhook-Signature header is valid.
74+
"""
75+
return self.validate_signature(json_payload, headers)
76+
77+
def parse_event(
78+
self, event_body: Union[str, Dict[str, Any]]
79+
) -> ConversationWebhookCallback:
80+
"""
81+
Parse the webhook payload into a typed event.
82+
83+
Parses by key: message_delivery_report → MessageDeliveryReceiptEvent,
84+
message → MessageInboundEvent, message_submit_notification → MessageSubmitEvent.
85+
Normalizes accepted_time and event_time. Injects trigger on the returned event.
86+
87+
:param event_body: JSON string or dict of the webhook body.
88+
:returns: Parsed event model.
89+
:raises ValueError: If JSON parsing fails or the payload is invalid.
90+
"""
91+
if isinstance(event_body, str):
92+
event_body = parse_json(event_body)
93+
94+
# Normalize timestamp fields
95+
for key in ("accepted_time", "event_time"):
96+
if key in event_body and isinstance(event_body[key], str):
97+
event_body[key] = normalize_iso_timestamp(event_body[key])
98+
99+
# Type is determined by which key is present (message_delivery_report, message,
100+
# message_submit_notification). Inject trigger so callers can use event.trigger.
101+
trigger = event_body.get("trigger")
102+
if not trigger and "message_delivery_report" in event_body:
103+
trigger = "MESSAGE_DELIVERY"
104+
if not trigger and "message" in event_body:
105+
trigger = "MESSAGE_INBOUND"
106+
if not trigger and "message_submit_notification" in event_body:
107+
trigger = "MESSAGE_SUBMIT"
108+
109+
if trigger == "MESSAGE_DELIVERY":
110+
event_body = {**event_body, "trigger": "MESSAGE_DELIVERY"}
111+
return MessageDeliveryReceiptEvent(**event_body)
112+
if trigger == "MESSAGE_INBOUND":
113+
event_body = {**event_body, "trigger": "MESSAGE_INBOUND"}
114+
return MessageInboundEvent(**event_body)
115+
if trigger == "MESSAGE_SUBMIT":
116+
event_body = {**event_body, "trigger": "MESSAGE_SUBMIT"}
117+
return MessageSubmitEvent(**event_body)
118+
119+
return ConversationWebhookEventBase(**event_body)

0 commit comments

Comments
 (0)