From b14d3f5356a7b1bd7cce1fbf59319a4462582b4e Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Sat, 21 Mar 2026 16:12:55 +0100 Subject: [PATCH 01/10] DEVEXP-1310: Redesign Webhooks - Conversation --- MIGRATION_GUIDE.md | 32 +++++++ .../send_handle_incoming_sms/controller.py | 4 +- .../server_business_logic.py | 4 +- examples/sinch_events/README.md | 2 +- .../conversation_api/controller.py | 6 +- .../conversation_api/server_business_logic.py | 10 +-- .../conversation/api/v1/messages_apis.py | 84 +++++++++---------- sinch/domains/conversation/conversation.py | 16 ++-- .../internal/request/send_message_request.py | 5 +- .../models/v1/sinch_events/__init__.py | 39 +++++++++ .../events/conversation_sinch_event_base.py} | 6 +- .../conversation_sinch_event_payload.py | 22 +++++ .../events/delivery_status_type.py | 0 .../events/inbound_message.py | 4 +- .../events/message_delivery_receipt_event.py | 17 ++++ .../events/message_delivery_report.py | 6 +- .../events/message_inbound_event.py | 16 ++++ .../events/message_submit_event.py | 16 ++++ .../events/message_submit_notification.py | 4 +- .../models/v1/webhooks/__init__.py | 39 --------- .../events/conversation_webhook_event.py | 22 ----- .../events/message_delivery_receipt_event.py | 17 ---- .../webhooks/events/message_inbound_event.py | 16 ---- .../webhooks/events/message_submit_event.py | 16 ---- .../conversation/sinch_events/v1/__init__.py | 5 ++ .../v1/conversation_sinch_event.py} | 47 +++++------ .../sinch_events/v1/internal/__init__.py | 5 ++ .../v1/internal/sinch_event.py} | 4 +- .../conversation/webhooks/v1/__init__.py | 5 -- .../webhooks/v1/internal/__init__.py | 5 -- .../features/steps/webhooks-events.steps.py | 22 ++--- .../request/test_send_message_request.py | 15 ++++ .../test_conversation_sinch_event_model.py} | 10 +-- .../test_conversation_sinch_event.py} | 44 +++++----- .../v1/utils/test_message_helpers.py | 4 +- 35 files changed, 306 insertions(+), 263 deletions(-) create mode 100644 sinch/domains/conversation/models/v1/sinch_events/__init__.py rename sinch/domains/conversation/models/v1/{webhooks/events/conversation_webhook_event_base.py => sinch_events/events/conversation_sinch_event_base.py} (83%) create mode 100644 sinch/domains/conversation/models/v1/sinch_events/events/conversation_sinch_event_payload.py rename sinch/domains/conversation/models/v1/{webhooks => sinch_events}/events/delivery_status_type.py (100%) rename sinch/domains/conversation/models/v1/{webhooks => sinch_events}/events/inbound_message.py (79%) create mode 100644 sinch/domains/conversation/models/v1/sinch_events/events/message_delivery_receipt_event.py rename sinch/domains/conversation/models/v1/{webhooks => sinch_events}/events/message_delivery_report.py (87%) create mode 100644 sinch/domains/conversation/models/v1/sinch_events/events/message_inbound_event.py create mode 100644 sinch/domains/conversation/models/v1/sinch_events/events/message_submit_event.py rename sinch/domains/conversation/models/v1/{webhooks => sinch_events}/events/message_submit_notification.py (92%) delete mode 100644 sinch/domains/conversation/models/v1/webhooks/__init__.py delete mode 100644 sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event.py delete mode 100644 sinch/domains/conversation/models/v1/webhooks/events/message_delivery_receipt_event.py delete mode 100644 sinch/domains/conversation/models/v1/webhooks/events/message_inbound_event.py delete mode 100644 sinch/domains/conversation/models/v1/webhooks/events/message_submit_event.py create mode 100644 sinch/domains/conversation/sinch_events/v1/__init__.py rename sinch/domains/conversation/{webhooks/v1/conversation_webhooks.py => sinch_events/v1/conversation_sinch_event.py} (73%) create mode 100644 sinch/domains/conversation/sinch_events/v1/internal/__init__.py rename sinch/domains/conversation/{webhooks/v1/internal/webhook_event.py => sinch_events/v1/internal/sinch_event.py} (52%) delete mode 100644 sinch/domains/conversation/webhooks/v1/__init__.py delete mode 100644 sinch/domains/conversation/webhooks/v1/internal/__init__.py rename tests/unit/domains/conversation/v1/models/{webhooks/events/test_conversation_webhooks_event_model.py => sinch_events/events/test_conversation_sinch_event_model.py} (91%) rename tests/unit/domains/conversation/v1/{webhooks/test_conversation_webhooks.py => sinch_events/test_conversation_sinch_event.py} (77%) diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 1733a2f4..aff85db8 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -126,6 +126,38 @@ The Conversation domain API access remains `sinch_client.conversation`; message | `list()` with `ListConversationMessagesRequest` | In Progress | | — | **New in V2:** `update()` with `message_id`, `metadata`, and optional `messages_source`| +##### Replacement APIs / attributes + +| Old | New | +|-----|-----| +| `sinch_client.conversation.webhook` (REST: create, list, get, update, delete webhooks; models under `sinch.domains.conversation.models.webhook`, e.g. `CreateConversationWebhookRequest`, `SinchListWebhooksResponse`) | Event Destinations REST for Conversation when added to the V2 SDK (naming aligns with `EventDestination`, list responses use `event_destinations`, …). | + +#### Sinch Events (Event Destinations payload models and package path) + +| Old | New | +|-----|-----| +| — _(N/A)_ | `sinch.domains.conversation.models.v1.sinch_events` (package path for inbound payload models) | +| — | [`ConversationSinchEvent`](sinch/domains/conversation/sinch_events/v1/conversation_sinch_event.py) (handler: signature validation and `parse_event`) | +| — | `ConversationSinchEventPayload`, `ConversationSinchEventBase`, and concrete event types (e.g. `MessageInboundEvent`, `MessageDeliveryReceiptEvent`, `MessageSubmitEvent`) | + +To obtain a Conversation Sinch Events handler: `sinch_client.conversation.sinch_events(callback_secret)` returns a [`ConversationSinchEvent`](sinch/domains/conversation/sinch_events/v1/conversation_sinch_event.py) instance; `handler.parse_event(request_body)` returns a `ConversationSinchEventPayload` (or a concrete event type). + +```python +# New +handler = sinch_client.conversation.sinch_events("your_callback_secret") +event = handler.parse_event(request_body) +``` + +#### Request and response fields: callback URL → event destination target + +| | Old | New | +|---|-----|-----| +| **Messages (`send`)** | `sinch.domains.conversation.models.message.requests.SendConversationMessageRequest` field `callback_url` | [`SendMessageRequest`](sinch/domains/conversation/models/v1/messages/internal/request/send_message_request.py) field `event_destination_target` | +| **Messages (methods)** | `ConversationMessage.send(..., callback_url=...)` | `sinch_client.conversation.messages.send()`, `send_text_message()`, and other `send_*_message()` methods with `event_destination_target=...` | +| **Send event** | `sinch.domains.conversation.models.event.requests.SendConversationEventRequest` field `callback_url` | `event_destination_target` on the V2 send-event request model when that API is exposed | + +The Conversation HTTP API still expects the JSON field **`callback_url`**. In V2, use the Python parameter / model field `event_destination_target`; it is serialized as `callback_url` on the wire (same pattern as other domains, e.g. SMS). +
### [`SMS`](https://github.com/sinch/sinch-sdk-python/tree/main/sinch/domains/sms) diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/controller.py b/examples/getting-started/conversation/send_handle_incoming_sms/controller.py index e5816460..0e187eee 100644 --- a/examples/getting-started/conversation/send_handle_incoming_sms/controller.py +++ b/examples/getting-started/conversation/send_handle_incoming_sms/controller.py @@ -11,8 +11,8 @@ def conversation_event(self): headers = dict(request.headers) raw_body = getattr(request, "raw_body", None) or b"" - webhooks_service = self.sinch_client.conversation.webhooks() - event = webhooks_service.parse_event(raw_body, headers) + sinch_events_service = self.sinch_client.conversation.sinch_events() + event = sinch_events_service.parse_event(raw_body, headers) handle_conversation_event( event=event, logger=self.logger, diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/server_business_logic.py b/examples/getting-started/conversation/send_handle_incoming_sms/server_business_logic.py index 28107874..6ed9986a 100644 --- a/examples/getting-started/conversation/send_handle_incoming_sms/server_business_logic.py +++ b/examples/getting-started/conversation/send_handle_incoming_sms/server_business_logic.py @@ -3,11 +3,11 @@ Uses channel identity (SMS + phone number) only; app is in DISPATCH mode. """ -from sinch.domains.conversation.models.v1.webhooks import MessageInboundEvent +from sinch.domains.conversation.models.v1.sinch_events import MessageInboundEvent def handle_conversation_event(event, logger, sinch_client): - """Webhook entry: handle only MESSAGE_INBOUND; delegate to inbound handler.""" + """Sinch Event entry: handle only MESSAGE_INBOUND; delegate to inbound handler.""" if not isinstance(event, MessageInboundEvent): return _handle_message_inbound(event, logger, sinch_client) diff --git a/examples/sinch_events/README.md b/examples/sinch_events/README.md index 826fe987..7547fb3f 100644 --- a/examples/sinch_events/README.md +++ b/examples/sinch_events/README.md @@ -40,7 +40,7 @@ This directory contains both the Event handlers and the server application (`ser ``` SMS_SINCH_EVENT_SECRET=Your Sinch SMS Sinch Event Secret ``` - - Conversation controller: Set the webhook secret you configured when creating the webhook (see [Conversation API callbacks](https://developers.sinch.com/docs/conversation/callbacks)): + - Conversation controller: Set the Sinch Event secret you configured for your Conversation app event destination (see [Conversation API callbacks](https://developers.sinch.com/docs/conversation/callbacks)): ``` CONVERSATION_SINCH_EVENT_SECRET=Your Conversation Sinch Event Secret ``` diff --git a/examples/sinch_events/conversation_api/controller.py b/examples/sinch_events/conversation_api/controller.py index b71a287b..5ac8c2c7 100644 --- a/examples/sinch_events/conversation_api/controller.py +++ b/examples/sinch_events/conversation_api/controller.py @@ -12,21 +12,21 @@ def conversation_event(self): headers = dict(request.headers) raw_body = request.raw_body if request.raw_body else b"" - webhooks_service = self.sinch_client.conversation.webhooks( + sinch_events_service = self.sinch_client.conversation.sinch_events( self.sinch_event_secret ) # Set to True to enforce signature validation (recommended in production) ensure_valid_signature = False if ensure_valid_signature: - valid = webhooks_service.validate_authentication_header( + valid = sinch_events_service.validate_authentication_header( headers=headers, json_payload=raw_body, ) if not valid: return Response(status=401) - event = webhooks_service.parse_event(raw_body, headers) + event = sinch_events_service.parse_event(raw_body, headers) handle_conversation_event(event=event, logger=self.logger) return Response(status=200) diff --git a/examples/sinch_events/conversation_api/server_business_logic.py b/examples/sinch_events/conversation_api/server_business_logic.py index 03ef74e9..57b7ede4 100644 --- a/examples/sinch_events/conversation_api/server_business_logic.py +++ b/examples/sinch_events/conversation_api/server_business_logic.py @@ -1,16 +1,16 @@ -from sinch.domains.conversation.models.v1.webhooks import ( - ConversationWebhookEventBase, +from sinch.domains.conversation.models.v1.sinch_events import ( + ConversationSinchEventBase, MessageDeliveryReceiptEvent, MessageInboundEvent, MessageSubmitEvent, ) -def handle_conversation_event(event: ConversationWebhookEventBase, logger): +def handle_conversation_event(event: ConversationSinchEventBase, logger): """ - Dispatch a Conversation webhook event to the appropriate handler by trigger type. + Dispatch a Conversation Sinch Event to the appropriate handler by trigger type. - :param event: Parsed webhook event (MessageDeliveryReceiptEvent, MessageInboundEvent, etc.). + :param event: Parsed Sinch Event (MessageDeliveryReceiptEvent, MessageInboundEvent, etc.). :param logger: Logger instance for output. """ if isinstance(event, MessageInboundEvent): diff --git a/sinch/domains/conversation/api/v1/messages_apis.py b/sinch/domains/conversation/api/v1/messages_apis.py index 3c95b3b1..c4833899 100644 --- a/sinch/domains/conversation/api/v1/messages_apis.py +++ b/sinch/domains/conversation/api/v1/messages_apis.py @@ -335,7 +335,7 @@ def _send_message_variant( message: object, message_cls: type, ttl: Optional[Union[str, int]] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, channel_priority_order: Optional[List[ConversationChannelType]] = None, channel_properties: Optional[Dict[str, str]] = None, message_metadata: Optional[str] = None, @@ -372,7 +372,7 @@ def _send_message_variant( recipient=recipient_model, message=send_message_request_body, ttl=ttl, - callback_url=callback_url, + event_destination_target=event_destination_target, channel_priority_order=channel_priority_order, channel_properties=channel_properties, message_metadata=message_metadata, @@ -395,7 +395,7 @@ def send( List[ChannelRecipientIdentityDict] ] = None, ttl: Optional[Union[str, int]] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, channel_priority_order: Optional[List[ConversationChannelType]] = None, channel_properties: Optional[Dict[str, str]] = None, message_metadata: Optional[str] = None, @@ -425,8 +425,8 @@ def send( :type recipient_identities: Optional[List[ChannelRecipientIdentityDict]] :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. :type ttl: Optional[Union[str, int]] - :param callback_url: Overwrites the default callback url for delivery receipts for this message. - :type callback_url: Optional[str] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. :type channel_priority_order: Optional[List[ConversationChannelType]] :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. @@ -471,7 +471,7 @@ def send( recipient=recipient, message=message, ttl=ttl, - callback_url=callback_url, + event_destination_target=event_destination_target, channel_priority_order=channel_priority_order, channel_properties=channel_properties, message_metadata=message_metadata, @@ -494,7 +494,7 @@ def send_text_message( List[ChannelRecipientIdentityDict] ] = None, ttl: Optional[Union[str, int]] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, channel_priority_order: Optional[List[ConversationChannelType]] = None, channel_properties: Optional[Dict[str, str]] = None, message_metadata: Optional[str] = None, @@ -524,8 +524,8 @@ def send_text_message( :type text: str :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. :type ttl: Optional[Union[str, int]] - :param callback_url: Overwrites the default callback url for delivery receipts for this message. - :type callback_url: Optional[str] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. :type channel_priority_order: Optional[List[ConversationChannelType]] :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. @@ -560,7 +560,7 @@ def send_text_message( message=TextMessage(text=text), message_cls=TextMessage, ttl=ttl, - callback_url=callback_url, + event_destination_target=event_destination_target, channel_priority_order=channel_priority_order, channel_properties=channel_properties, message_metadata=message_metadata, @@ -582,7 +582,7 @@ def send_card_message( List[ChannelRecipientIdentityDict] ] = None, ttl: Optional[Union[str, int]] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, channel_priority_order: Optional[List[ConversationChannelType]] = None, channel_properties: Optional[Dict[str, str]] = None, message_metadata: Optional[str] = None, @@ -612,8 +612,8 @@ def send_card_message( :type card_message: CardMessageDict :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. :type ttl: Optional[Union[str, int]] - :param callback_url: Overwrites the default callback url for delivery receipts for this message. - :type callback_url: Optional[str] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. :type channel_priority_order: Optional[List[ConversationChannelType]] :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. @@ -648,7 +648,7 @@ def send_card_message( message=card_message, message_cls=CardMessage, ttl=ttl, - callback_url=callback_url, + event_destination_target=event_destination_target, channel_priority_order=channel_priority_order, channel_properties=channel_properties, message_metadata=message_metadata, @@ -670,7 +670,7 @@ def send_carousel_message( List[ChannelRecipientIdentityDict] ] = None, ttl: Optional[Union[str, int]] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, channel_priority_order: Optional[List[ConversationChannelType]] = None, channel_properties: Optional[Dict[str, str]] = None, message_metadata: Optional[str] = None, @@ -700,8 +700,8 @@ def send_carousel_message( :type carousel_message: CarouselMessageDict :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. :type ttl: Optional[Union[str, int]] - :param callback_url: Overwrites the default callback url for delivery receipts for this message. - :type callback_url: Optional[str] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. :type channel_priority_order: Optional[List[ConversationChannelType]] :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. @@ -736,7 +736,7 @@ def send_carousel_message( message=carousel_message, message_cls=CarouselMessage, ttl=ttl, - callback_url=callback_url, + event_destination_target=event_destination_target, channel_priority_order=channel_priority_order, channel_properties=channel_properties, message_metadata=message_metadata, @@ -758,7 +758,7 @@ def send_choice_message( List[ChannelRecipientIdentityDict] ] = None, ttl: Optional[Union[str, int]] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, channel_priority_order: Optional[List[ConversationChannelType]] = None, channel_properties: Optional[Dict[str, str]] = None, message_metadata: Optional[str] = None, @@ -788,8 +788,8 @@ def send_choice_message( :type choice_message: ChoiceMessageDict :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. :type ttl: Optional[Union[str, int]] - :param callback_url: Overwrites the default callback url for delivery receipts for this message. - :type callback_url: Optional[str] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. :type channel_priority_order: Optional[List[ConversationChannelType]] :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. @@ -824,7 +824,7 @@ def send_choice_message( message=choice_message, message_cls=ChoiceMessage, ttl=ttl, - callback_url=callback_url, + event_destination_target=event_destination_target, channel_priority_order=channel_priority_order, channel_properties=channel_properties, message_metadata=message_metadata, @@ -846,7 +846,7 @@ def send_contact_info_message( List[ChannelRecipientIdentityDict] ] = None, ttl: Optional[Union[str, int]] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, channel_priority_order: Optional[List[ConversationChannelType]] = None, channel_properties: Optional[Dict[str, str]] = None, message_metadata: Optional[str] = None, @@ -876,8 +876,8 @@ def send_contact_info_message( :type contact_info_message: ContactInfoMessageDict :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. :type ttl: Optional[Union[str, int]] - :param callback_url: Overwrites the default callback url for delivery receipts for this message. - :type callback_url: Optional[str] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. :type channel_priority_order: Optional[List[ConversationChannelType]] :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. @@ -912,7 +912,7 @@ def send_contact_info_message( message=contact_info_message, message_cls=ContactInfoMessage, ttl=ttl, - callback_url=callback_url, + event_destination_target=event_destination_target, channel_priority_order=channel_priority_order, channel_properties=channel_properties, message_metadata=message_metadata, @@ -934,7 +934,7 @@ def send_list_message( List[ChannelRecipientIdentityDict] ] = None, ttl: Optional[Union[str, int]] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, channel_priority_order: Optional[List[ConversationChannelType]] = None, channel_properties: Optional[Dict[str, str]] = None, message_metadata: Optional[str] = None, @@ -964,8 +964,8 @@ def send_list_message( :type list_message: ListMessageDict :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. :type ttl: Optional[Union[str, int]] - :param callback_url: Overwrites the default callback url for delivery receipts for this message. - :type callback_url: Optional[str] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. :type channel_priority_order: Optional[List[ConversationChannelType]] :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. @@ -1000,7 +1000,7 @@ def send_list_message( message=list_message, message_cls=ListMessage, ttl=ttl, - callback_url=callback_url, + event_destination_target=event_destination_target, channel_priority_order=channel_priority_order, channel_properties=channel_properties, message_metadata=message_metadata, @@ -1022,7 +1022,7 @@ def send_location_message( List[ChannelRecipientIdentityDict] ] = None, ttl: Optional[Union[str, int]] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, channel_priority_order: Optional[List[ConversationChannelType]] = None, channel_properties: Optional[Dict[str, str]] = None, message_metadata: Optional[str] = None, @@ -1052,8 +1052,8 @@ def send_location_message( :type location_message: LocationMessageDict :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. :type ttl: Optional[Union[str, int]] - :param callback_url: Overwrites the default callback url for delivery receipts for this message. - :type callback_url: Optional[str] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. :type channel_priority_order: Optional[List[ConversationChannelType]] :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. @@ -1088,7 +1088,7 @@ def send_location_message( message=location_message, message_cls=LocationMessage, ttl=ttl, - callback_url=callback_url, + event_destination_target=event_destination_target, channel_priority_order=channel_priority_order, channel_properties=channel_properties, message_metadata=message_metadata, @@ -1110,7 +1110,7 @@ def send_media_message( List[ChannelRecipientIdentityDict] ] = None, ttl: Optional[Union[str, int]] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, channel_priority_order: Optional[List[ConversationChannelType]] = None, channel_properties: Optional[Dict[str, str]] = None, message_metadata: Optional[str] = None, @@ -1140,8 +1140,8 @@ def send_media_message( :type media_message: MediaPropertiesDict :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. :type ttl: Optional[Union[str, int]] - :param callback_url: Overwrites the default callback url for delivery receipts for this message. - :type callback_url: Optional[str] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. :type channel_priority_order: Optional[List[ConversationChannelType]] :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. @@ -1176,7 +1176,7 @@ def send_media_message( message=media_message, message_cls=MediaProperties, ttl=ttl, - callback_url=callback_url, + event_destination_target=event_destination_target, channel_priority_order=channel_priority_order, channel_properties=channel_properties, message_metadata=message_metadata, @@ -1198,7 +1198,7 @@ def send_template_message( List[ChannelRecipientIdentityDict] ] = None, ttl: Optional[Union[str, int]] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, channel_priority_order: Optional[List[ConversationChannelType]] = None, channel_properties: Optional[Dict[str, str]] = None, message_metadata: Optional[str] = None, @@ -1228,8 +1228,8 @@ def send_template_message( :type template_message: TemplateMessageDict :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. :type ttl: Optional[Union[str, int]] - :param callback_url: Overwrites the default callback url for delivery receipts for this message. - :type callback_url: Optional[str] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. :type channel_priority_order: Optional[List[ConversationChannelType]] :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. @@ -1264,7 +1264,7 @@ def send_template_message( message=template_message, message_cls=TemplateMessage, ttl=ttl, - callback_url=callback_url, + event_destination_target=event_destination_target, channel_priority_order=channel_priority_order, channel_properties=channel_properties, message_metadata=message_metadata, diff --git a/sinch/domains/conversation/conversation.py b/sinch/domains/conversation/conversation.py index f9f06685..f2eaa2b3 100644 --- a/sinch/domains/conversation/conversation.py +++ b/sinch/domains/conversation/conversation.py @@ -1,7 +1,7 @@ from sinch.domains.conversation.api.v1 import ( Messages, ) -from sinch.domains.conversation.webhooks.v1 import ConversationWebhooks +from sinch.domains.conversation.sinch_events.v1 import ConversationSinchEvent class Conversation: @@ -14,13 +14,15 @@ def __init__(self, sinch): self._sinch = sinch self.messages = Messages(self._sinch) - def webhooks(self, callback_secret: str = "") -> ConversationWebhooks: + def sinch_events( + self, callback_secret: str = "" + ) -> ConversationSinchEvent: """ - Create a Conversation API webhooks handler with the given webhook secret. + Create a Conversation API Sinch Events handler with the given callback secret. - :param callback_secret: Secret used for webhook signature validation. + :param callback_secret: Secret used for Sinch Event signature validation. :type callback_secret: str - :returns: A configured webhooks handler. - :rtype: ConversationWebhooks + :returns: A configured Sinch Events handler. + :rtype: ConversationSinchEvent """ - return ConversationWebhooks(callback_secret) + return ConversationSinchEvent(callback_secret) diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/send_message_request.py b/sinch/domains/conversation/models/v1/messages/internal/request/send_message_request.py index accf6f61..96eafc99 100644 --- a/sinch/domains/conversation/models/v1/messages/internal/request/send_message_request.py +++ b/sinch/domains/conversation/models/v1/messages/internal/request/send_message_request.py @@ -44,9 +44,10 @@ class SendMessageRequest(BaseModelConfiguration): default=None, description="The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'.", ) - callback_url: Optional[StrictStr] = Field( + event_destination_target: Optional[StrictStr] = Field( default=None, - description="Overwrites the default callback url for delivery receipts for this message.", + alias="callback_url", + description="Overwrites the default event destination target for delivery receipts for this message.", ) channel_priority_order: Optional[List[ConversationChannelType]] = Field( default=None, diff --git a/sinch/domains/conversation/models/v1/sinch_events/__init__.py b/sinch/domains/conversation/models/v1/sinch_events/__init__.py new file mode 100644 index 00000000..3e83f5b9 --- /dev/null +++ b/sinch/domains/conversation/models/v1/sinch_events/__init__.py @@ -0,0 +1,39 @@ +from sinch.domains.conversation.models.v1.sinch_events.events.conversation_sinch_event_payload import ( + ConversationSinchEventPayload, +) +from sinch.domains.conversation.models.v1.sinch_events.events.conversation_sinch_event_base import ( + ConversationSinchEventBase, +) +from sinch.domains.conversation.models.v1.sinch_events.events.delivery_status_type import ( + DeliveryStatusType, +) +from sinch.domains.conversation.models.v1.sinch_events.events.inbound_message import ( + InboundMessage, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_delivery_receipt_event import ( + MessageDeliveryReceiptEvent, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_delivery_report import ( + MessageDeliveryReport, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_inbound_event import ( + MessageInboundEvent, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_submit_event import ( + MessageSubmitEvent, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_submit_notification import ( + MessageSubmitNotification, +) + +__all__ = [ + "ConversationSinchEventPayload", + "ConversationSinchEventBase", + "InboundMessage", + "MessageDeliveryReceiptEvent", + "MessageDeliveryReport", + "DeliveryStatusType", + "MessageInboundEvent", + "MessageSubmitEvent", + "MessageSubmitNotification", +] diff --git a/sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event_base.py b/sinch/domains/conversation/models/v1/sinch_events/events/conversation_sinch_event_base.py similarity index 83% rename from sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event_base.py rename to sinch/domains/conversation/models/v1/sinch_events/events/conversation_sinch_event_base.py index 26cf3eea..114da8b5 100644 --- a/sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event_base.py +++ b/sinch/domains/conversation/models/v1/sinch_events/events/conversation_sinch_event_base.py @@ -3,11 +3,11 @@ from pydantic import Field, StrictStr -from sinch.domains.conversation.webhooks.v1.internal import WebhookEvent +from sinch.domains.conversation.sinch_events.v1.internal import SinchEvent -class ConversationWebhookEventBase(WebhookEvent): - """Base fields present on every Conversation API webhook payload.""" +class ConversationSinchEventBase(SinchEvent): + """Base fields present on every Conversation API Sinch Event payload.""" app_id: Optional[StrictStr] = Field( default=None, diff --git a/sinch/domains/conversation/models/v1/sinch_events/events/conversation_sinch_event_payload.py b/sinch/domains/conversation/models/v1/sinch_events/events/conversation_sinch_event_payload.py new file mode 100644 index 00000000..71e9bce0 --- /dev/null +++ b/sinch/domains/conversation/models/v1/sinch_events/events/conversation_sinch_event_payload.py @@ -0,0 +1,22 @@ +from typing import Union + +from sinch.domains.conversation.models.v1.sinch_events.events.conversation_sinch_event_base import ( + ConversationSinchEventBase, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_delivery_receipt_event import ( + MessageDeliveryReceiptEvent, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_inbound_event import ( + MessageInboundEvent, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_submit_event import ( + MessageSubmitEvent, +) + + +ConversationSinchEventPayload = Union[ + MessageDeliveryReceiptEvent, + MessageInboundEvent, + MessageSubmitEvent, + ConversationSinchEventBase, +] diff --git a/sinch/domains/conversation/models/v1/webhooks/events/delivery_status_type.py b/sinch/domains/conversation/models/v1/sinch_events/events/delivery_status_type.py similarity index 100% rename from sinch/domains/conversation/models/v1/webhooks/events/delivery_status_type.py rename to sinch/domains/conversation/models/v1/sinch_events/events/delivery_status_type.py diff --git a/sinch/domains/conversation/models/v1/webhooks/events/inbound_message.py b/sinch/domains/conversation/models/v1/sinch_events/events/inbound_message.py similarity index 79% rename from sinch/domains/conversation/models/v1/webhooks/events/inbound_message.py rename to sinch/domains/conversation/models/v1/sinch_events/events/inbound_message.py index 2b4d45ec..422e2cc9 100644 --- a/sinch/domains/conversation/models/v1/webhooks/events/inbound_message.py +++ b/sinch/domains/conversation/models/v1/sinch_events/events/inbound_message.py @@ -2,7 +2,7 @@ from pydantic import Field -from sinch.domains.conversation.webhooks.v1.internal import WebhookEvent +from sinch.domains.conversation.sinch_events.v1.internal import SinchEvent from sinch.domains.conversation.models.v1.messages.shared.message_common_props import ( MessageCommonProps, ) @@ -11,7 +11,7 @@ ) -class InboundMessage(MessageCommonProps, WebhookEvent): +class InboundMessage(MessageCommonProps, SinchEvent): """Inbound message container (contact message + channel/contact info).""" contact_message: Optional[ContactMessage] = Field( diff --git a/sinch/domains/conversation/models/v1/sinch_events/events/message_delivery_receipt_event.py b/sinch/domains/conversation/models/v1/sinch_events/events/message_delivery_receipt_event.py new file mode 100644 index 00000000..6c344458 --- /dev/null +++ b/sinch/domains/conversation/models/v1/sinch_events/events/message_delivery_receipt_event.py @@ -0,0 +1,17 @@ +from pydantic import Field + +from sinch.domains.conversation.models.v1.sinch_events.events.conversation_sinch_event_base import ( + ConversationSinchEventBase, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_delivery_report import ( + MessageDeliveryReport, +) + + +class MessageDeliveryReceiptEvent(ConversationSinchEventBase): + """Sinch Event for MESSAGE_DELIVERY (delivery receipt for app messages).""" + + message_delivery_report: MessageDeliveryReport = Field( + default=None, + description="The delivery report payload.", + ) diff --git a/sinch/domains/conversation/models/v1/webhooks/events/message_delivery_report.py b/sinch/domains/conversation/models/v1/sinch_events/events/message_delivery_report.py similarity index 87% rename from sinch/domains/conversation/models/v1/webhooks/events/message_delivery_report.py rename to sinch/domains/conversation/models/v1/sinch_events/events/message_delivery_report.py index 222e3bba..43f1ae2c 100644 --- a/sinch/domains/conversation/models/v1/webhooks/events/message_delivery_report.py +++ b/sinch/domains/conversation/models/v1/sinch_events/events/message_delivery_report.py @@ -2,7 +2,7 @@ from pydantic import Field, StrictStr -from sinch.domains.conversation.webhooks.v1.internal import WebhookEvent +from sinch.domains.conversation.sinch_events.v1.internal import SinchEvent from sinch.domains.conversation.models.v1.messages.shared import ( ChannelIdentity, Reason, @@ -11,12 +11,12 @@ ProcessingModeType, ) -from sinch.domains.conversation.models.v1.webhooks.events.delivery_status_type import ( +from sinch.domains.conversation.models.v1.sinch_events.events.delivery_status_type import ( DeliveryStatusType, ) -class MessageDeliveryReport(WebhookEvent): +class MessageDeliveryReport(SinchEvent): """Delivery report for an app message (MESSAGE_DELIVERY trigger).""" message_id: Optional[StrictStr] = Field( diff --git a/sinch/domains/conversation/models/v1/sinch_events/events/message_inbound_event.py b/sinch/domains/conversation/models/v1/sinch_events/events/message_inbound_event.py new file mode 100644 index 00000000..540ddd49 --- /dev/null +++ b/sinch/domains/conversation/models/v1/sinch_events/events/message_inbound_event.py @@ -0,0 +1,16 @@ +from pydantic import Field + +from sinch.domains.conversation.models.v1.sinch_events.events.conversation_sinch_event_base import ( + ConversationSinchEventBase, +) +from sinch.domains.conversation.models.v1.sinch_events.events.inbound_message import ( + InboundMessage, +) + + +class MessageInboundEvent(ConversationSinchEventBase): + """Sinch Event for MESSAGE_INBOUND (inbound message from user).""" + + message: InboundMessage = Field( + description="The inbound message payload.", + ) diff --git a/sinch/domains/conversation/models/v1/sinch_events/events/message_submit_event.py b/sinch/domains/conversation/models/v1/sinch_events/events/message_submit_event.py new file mode 100644 index 00000000..fe7026c9 --- /dev/null +++ b/sinch/domains/conversation/models/v1/sinch_events/events/message_submit_event.py @@ -0,0 +1,16 @@ +from pydantic import Field + +from sinch.domains.conversation.models.v1.sinch_events.events.conversation_sinch_event_base import ( + ConversationSinchEventBase, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_submit_notification import ( + MessageSubmitNotification, +) + + +class MessageSubmitEvent(ConversationSinchEventBase): + """Sinch Event for MESSAGE_SUBMIT (message submission notification).""" + + message_submit_notification: MessageSubmitNotification = Field( + description="The message submit notification payload.", + ) diff --git a/sinch/domains/conversation/models/v1/webhooks/events/message_submit_notification.py b/sinch/domains/conversation/models/v1/sinch_events/events/message_submit_notification.py similarity index 92% rename from sinch/domains/conversation/models/v1/webhooks/events/message_submit_notification.py rename to sinch/domains/conversation/models/v1/sinch_events/events/message_submit_notification.py index 69ac499b..ba09c56b 100644 --- a/sinch/domains/conversation/models/v1/webhooks/events/message_submit_notification.py +++ b/sinch/domains/conversation/models/v1/sinch_events/events/message_submit_notification.py @@ -2,7 +2,7 @@ from pydantic import Field, StrictStr -from sinch.domains.conversation.webhooks.v1.internal import WebhookEvent +from sinch.domains.conversation.sinch_events.v1.internal import SinchEvent from sinch.domains.conversation.models.v1.messages.shared import ( ChannelIdentity, ) @@ -14,7 +14,7 @@ ) -class MessageSubmitNotification(WebhookEvent): +class MessageSubmitNotification(SinchEvent): """Notification that an app message was submitted (MESSAGE_SUBMIT trigger).""" message_id: Optional[StrictStr] = Field( diff --git a/sinch/domains/conversation/models/v1/webhooks/__init__.py b/sinch/domains/conversation/models/v1/webhooks/__init__.py deleted file mode 100644 index bf06e529..00000000 --- a/sinch/domains/conversation/models/v1/webhooks/__init__.py +++ /dev/null @@ -1,39 +0,0 @@ -from sinch.domains.conversation.models.v1.webhooks.events.conversation_webhook_event import ( - ConversationWebhookEvent, -) -from sinch.domains.conversation.models.v1.webhooks.events.conversation_webhook_event_base import ( - ConversationWebhookEventBase, -) -from sinch.domains.conversation.models.v1.webhooks.events.delivery_status_type import ( - DeliveryStatusType, -) -from sinch.domains.conversation.models.v1.webhooks.events.inbound_message import ( - InboundMessage, -) -from sinch.domains.conversation.models.v1.webhooks.events.message_delivery_receipt_event import ( - MessageDeliveryReceiptEvent, -) -from sinch.domains.conversation.models.v1.webhooks.events.message_delivery_report import ( - MessageDeliveryReport, -) -from sinch.domains.conversation.models.v1.webhooks.events.message_inbound_event import ( - MessageInboundEvent, -) -from sinch.domains.conversation.models.v1.webhooks.events.message_submit_event import ( - MessageSubmitEvent, -) -from sinch.domains.conversation.models.v1.webhooks.events.message_submit_notification import ( - MessageSubmitNotification, -) - -__all__ = [ - "ConversationWebhookEvent", - "ConversationWebhookEventBase", - "InboundMessage", - "MessageDeliveryReceiptEvent", - "MessageDeliveryReport", - "DeliveryStatusType", - "MessageInboundEvent", - "MessageSubmitEvent", - "MessageSubmitNotification", -] diff --git a/sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event.py b/sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event.py deleted file mode 100644 index 8a7d07dd..00000000 --- a/sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event.py +++ /dev/null @@ -1,22 +0,0 @@ -from typing import Union - -from sinch.domains.conversation.models.v1.webhooks.events.conversation_webhook_event_base import ( - ConversationWebhookEventBase, -) -from sinch.domains.conversation.models.v1.webhooks.events.message_delivery_receipt_event import ( - MessageDeliveryReceiptEvent, -) -from sinch.domains.conversation.models.v1.webhooks.events.message_inbound_event import ( - MessageInboundEvent, -) -from sinch.domains.conversation.models.v1.webhooks.events.message_submit_event import ( - MessageSubmitEvent, -) - - -ConversationWebhookEvent = Union[ - MessageDeliveryReceiptEvent, - MessageInboundEvent, - MessageSubmitEvent, - ConversationWebhookEventBase, -] diff --git a/sinch/domains/conversation/models/v1/webhooks/events/message_delivery_receipt_event.py b/sinch/domains/conversation/models/v1/webhooks/events/message_delivery_receipt_event.py deleted file mode 100644 index 79ef1a9b..00000000 --- a/sinch/domains/conversation/models/v1/webhooks/events/message_delivery_receipt_event.py +++ /dev/null @@ -1,17 +0,0 @@ -from pydantic import Field - -from sinch.domains.conversation.models.v1.webhooks.events.conversation_webhook_event_base import ( - ConversationWebhookEventBase, -) -from sinch.domains.conversation.models.v1.webhooks.events.message_delivery_report import ( - MessageDeliveryReport, -) - - -class MessageDeliveryReceiptEvent(ConversationWebhookEventBase): - """Webhook event for MESSAGE_DELIVERY (delivery receipt for app messages).""" - - message_delivery_report: MessageDeliveryReport = Field( - default=None, - description="The delivery report payload.", - ) diff --git a/sinch/domains/conversation/models/v1/webhooks/events/message_inbound_event.py b/sinch/domains/conversation/models/v1/webhooks/events/message_inbound_event.py deleted file mode 100644 index 89732cb7..00000000 --- a/sinch/domains/conversation/models/v1/webhooks/events/message_inbound_event.py +++ /dev/null @@ -1,16 +0,0 @@ -from pydantic import Field - -from sinch.domains.conversation.models.v1.webhooks.events.conversation_webhook_event_base import ( - ConversationWebhookEventBase, -) -from sinch.domains.conversation.models.v1.webhooks.events.inbound_message import ( - InboundMessage, -) - - -class MessageInboundEvent(ConversationWebhookEventBase): - """Webhook event for MESSAGE_INBOUND (inbound message from user).""" - - message: InboundMessage = Field( - description="The inbound message payload.", - ) diff --git a/sinch/domains/conversation/models/v1/webhooks/events/message_submit_event.py b/sinch/domains/conversation/models/v1/webhooks/events/message_submit_event.py deleted file mode 100644 index 6e539c9b..00000000 --- a/sinch/domains/conversation/models/v1/webhooks/events/message_submit_event.py +++ /dev/null @@ -1,16 +0,0 @@ -from pydantic import Field - -from sinch.domains.conversation.models.v1.webhooks.events.conversation_webhook_event_base import ( - ConversationWebhookEventBase, -) -from sinch.domains.conversation.models.v1.webhooks.events.message_submit_notification import ( - MessageSubmitNotification, -) - - -class MessageSubmitEvent(ConversationWebhookEventBase): - """Webhook event for MESSAGE_SUBMIT (message submission notification).""" - - message_submit_notification: MessageSubmitNotification = Field( - description="The message submit notification payload.", - ) diff --git a/sinch/domains/conversation/sinch_events/v1/__init__.py b/sinch/domains/conversation/sinch_events/v1/__init__.py new file mode 100644 index 00000000..93471398 --- /dev/null +++ b/sinch/domains/conversation/sinch_events/v1/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.conversation.sinch_events.v1.conversation_sinch_event import ( + ConversationSinchEvent, +) + +__all__ = ["ConversationSinchEvent"] diff --git a/sinch/domains/conversation/webhooks/v1/conversation_webhooks.py b/sinch/domains/conversation/sinch_events/v1/conversation_sinch_event.py similarity index 73% rename from sinch/domains/conversation/webhooks/v1/conversation_webhooks.py rename to sinch/domains/conversation/sinch_events/v1/conversation_sinch_event.py index 2acadc98..5e205f70 100644 --- a/sinch/domains/conversation/webhooks/v1/conversation_webhooks.py +++ b/sinch/domains/conversation/sinch_events/v1/conversation_sinch_event.py @@ -8,8 +8,9 @@ parse_json, normalize_iso_timestamp, ) -from sinch.domains.conversation.models.v1.webhooks import ( - ConversationWebhookEventBase, +from sinch.domains.conversation.models.v1.sinch_events import ( + ConversationSinchEventBase, + ConversationSinchEventPayload, MessageDeliveryReceiptEvent, MessageInboundEvent, MessageSubmitEvent, @@ -19,33 +20,25 @@ logger = logging.getLogger(__name__) -ConversationWebhookCallback = Union[ - MessageDeliveryReceiptEvent, - MessageInboundEvent, - MessageSubmitEvent, - ConversationWebhookEventBase, -] - - -class ConversationWebhooks: +class ConversationSinchEvent: """ - Handler for Conversation API webhooks: validate signature and parse events. + Handler for Conversation API Sinch Events: validate signature and parse events. """ - def __init__(self, webhook_secret: Optional[str] = None): + def __init__(self, callback_secret: Optional[str] = None): """ - :param webhook_secret: Secret configured for the webhook (used for HMAC validation). + :param callback_secret: Secret configured for the event destination (used for HMAC validation). """ - self.webhook_secret = webhook_secret + self.callback_secret = callback_secret def _validate_signature( self, payload: Union[str, bytes], headers: Dict[str, str], - webhook_secret: Optional[str] = None, + callback_secret: Optional[str] = None, ) -> bool: """ - Validate the webhook signature using the request body and headers. + Validate the Sinch Event signature using the request body and headers. Uses x-sinch-webhook-signature, x-sinch-webhook-signature-nonce, and x-sinch-webhook-signature-timestamp. Returns True only if the signature @@ -53,13 +46,13 @@ def _validate_signature( :param payload: Raw request body (string or bytes). :param headers: Incoming request headers (key case is normalized to lower). - :param webhook_secret: Secret for this webhook; defaults to the secret passed to __init__. + :param callback_secret: Secret for this request; defaults to the secret passed to __init__. :returns: True if the signature is valid, False otherwise. """ secret = ( - webhook_secret - if webhook_secret is not None - else self.webhook_secret + callback_secret + if callback_secret is not None + else self.callback_secret ) if not secret: return False @@ -74,7 +67,7 @@ def validate_authentication_header( json_payload: Union[str, bytes], ) -> bool: """ - Validate the webhook signature (convenience wrapper around internal validation). + Validate the Sinch Event signature (convenience wrapper around internal validation). :param headers: Incoming request's headers. :param json_payload: Incoming request's raw body (str or bytes). @@ -86,15 +79,15 @@ def parse_event( self, event_body: Union[str, bytes, Dict[str, Any]], headers: Optional[Dict[str, str]] = None, - ) -> ConversationWebhookCallback: + ) -> ConversationSinchEventPayload: """ - Parse the webhook payload into a typed event. + Parse the Sinch Event payload into a typed event. Parses by key: message_delivery_report → MessageDeliveryReceiptEvent, message → MessageInboundEvent, message_submit_notification → MessageSubmitEvent. Normalizes accepted_time and event_time. Injects trigger on the returned event. - :param event_body: JSON string, raw bytes, or dict of the webhook body. + :param event_body: JSON string, raw bytes, or dict of the event body. :param headers: Request headers (used to decode charset when event_body is bytes). :returns: Parsed event model. :raises ValueError: If JSON parsing fails or the payload is invalid. @@ -119,6 +112,6 @@ def parse_event( return MessageSubmitEvent(**event_body) logger.warning( - "Conversation webhook: unknown event type; returning base event." + "Conversation Sinch Event: unknown event type; returning base event." ) - return ConversationWebhookEventBase(**event_body) + return ConversationSinchEventBase(**event_body) diff --git a/sinch/domains/conversation/sinch_events/v1/internal/__init__.py b/sinch/domains/conversation/sinch_events/v1/internal/__init__.py new file mode 100644 index 00000000..cd400379 --- /dev/null +++ b/sinch/domains/conversation/sinch_events/v1/internal/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.conversation.sinch_events.v1.internal.sinch_event import ( + SinchEvent, +) + +__all__ = ["SinchEvent"] diff --git a/sinch/domains/conversation/webhooks/v1/internal/webhook_event.py b/sinch/domains/conversation/sinch_events/v1/internal/sinch_event.py similarity index 52% rename from sinch/domains/conversation/webhooks/v1/internal/webhook_event.py rename to sinch/domains/conversation/sinch_events/v1/internal/sinch_event.py index 3c2e597e..22eefc3d 100644 --- a/sinch/domains/conversation/webhooks/v1/internal/webhook_event.py +++ b/sinch/domains/conversation/sinch_events/v1/internal/sinch_event.py @@ -3,7 +3,7 @@ ) -class WebhookEvent(BaseModelConfiguration): - """Base model for Conversation API webhook events.""" +class SinchEvent(BaseModelConfiguration): + """Base model for Conversation API Sinch Event payloads.""" pass diff --git a/sinch/domains/conversation/webhooks/v1/__init__.py b/sinch/domains/conversation/webhooks/v1/__init__.py deleted file mode 100644 index 37b2f39f..00000000 --- a/sinch/domains/conversation/webhooks/v1/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from sinch.domains.conversation.webhooks.v1.conversation_webhooks import ( - ConversationWebhooks, -) - -__all__ = ["ConversationWebhooks"] diff --git a/sinch/domains/conversation/webhooks/v1/internal/__init__.py b/sinch/domains/conversation/webhooks/v1/internal/__init__.py deleted file mode 100644 index 37ec3ff5..00000000 --- a/sinch/domains/conversation/webhooks/v1/internal/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from sinch.domains.conversation.webhooks.v1.internal.webhook_event import ( - WebhookEvent, -) - -__all__ = ["WebhookEvent"] diff --git a/tests/e2e/conversation/features/steps/webhooks-events.steps.py b/tests/e2e/conversation/features/steps/webhooks-events.steps.py index 7c066f39..b4f95085 100644 --- a/tests/e2e/conversation/features/steps/webhooks-events.steps.py +++ b/tests/e2e/conversation/features/steps/webhooks-events.steps.py @@ -1,7 +1,7 @@ import requests from behave import given, when, then -from sinch.domains.conversation.webhooks.v1 import ConversationWebhooks -from sinch.domains.conversation.models.v1.webhooks import ( +from sinch.domains.conversation.sinch_events.v1 import ConversationSinchEvent +from sinch.domains.conversation.models.v1.sinch_events import ( MessageDeliveryReceiptEvent, MessageInboundEvent, MessageSubmitEvent, @@ -14,7 +14,7 @@ def process_event(context, response): store_webhook_response(context, response) - context.event = context.conversation_webhooks.parse_event(context.raw_event) + context.event = context.conversation_sinch_events.parse_event(context.raw_event) def _fetch_and_process(context, path_suffix): @@ -28,7 +28,7 @@ def _fetch_and_process(context, path_suffix): def step_conversation_webhooks_available(context): context.sinch.configuration.auth_origin = "http://localhost:3014" context.sinch.configuration.conversation_origin = "http://localhost:3014" - context.conversation_webhooks = ConversationWebhooks(APP_SECRET) + context.conversation_sinch_events = ConversationSinchEvent(APP_SECRET) # --- CAPABILITY --- @@ -168,7 +168,7 @@ def step_trigger_event_delivery_failed(context): @then('the header of the Conversation event "EVENT_DELIVERY" with a "FAILED" status contains a valid signature') def step_signature_valid_event_delivery_failed(context): - assert context.conversation_webhooks.validate_authentication_header( + assert context.conversation_sinch_events.validate_authentication_header( context.webhook_headers, context.raw_event ), "Signature validation failed for event EVENT_DELIVERY with status FAILED" @@ -191,7 +191,7 @@ def step_trigger_event_delivery_delivered(context): @then('the header of the Conversation event "EVENT_DELIVERY" with a "DELIVERED" status contains a valid signature') def step_signature_valid_event_delivery_delivered(context): - assert context.conversation_webhooks.validate_authentication_header( + assert context.conversation_sinch_events.validate_authentication_header( context.webhook_headers, context.raw_event ), "Signature validation failed for event EVENT_DELIVERY with status DELIVERED" @@ -220,7 +220,7 @@ def step_trigger_message_delivery_failed(context): @then('the header of the Conversation event "MESSAGE_DELIVERY" with a "FAILED" status contains a valid signature') def step_signature_valid_message_delivery_failed(context): - assert context.conversation_webhooks.validate_authentication_header( + assert context.conversation_sinch_events.validate_authentication_header( context.webhook_headers, context.raw_event ), "Signature validation failed for event MESSAGE_DELIVERY with status FAILED" @@ -255,7 +255,7 @@ def step_trigger_message_delivery_queued(context): @then('the header of the Conversation event "MESSAGE_DELIVERY" with a "QUEUED_ON_CHANNEL" status contains a valid signature') def step_signature_valid_message_delivery_queued(context): - assert context.conversation_webhooks.validate_authentication_header( + assert context.conversation_sinch_events.validate_authentication_header( context.webhook_headers, context.raw_event ), "Signature validation failed for event MESSAGE_DELIVERY with status QUEUED_ON_CHANNEL" @@ -268,7 +268,7 @@ def step_trigger_message_inbound(context): @then('the header of the Conversation event "MESSAGE_INBOUND" contains a valid signature') def step_signature_valid_message_inbound(context): - assert context.conversation_webhooks.validate_authentication_header( + assert context.conversation_sinch_events.validate_authentication_header( context.webhook_headers, context.raw_event ), "Signature validation failed for event MESSAGE_INBOUND" @@ -306,7 +306,7 @@ def step_trigger_message_submit_media(context): @then('the header of the Conversation event "MESSAGE_SUBMIT" for a "media" message contains a valid signature') def step_signature_valid_message_submit_media(context): - assert context.conversation_webhooks.validate_authentication_header( + assert context.conversation_sinch_events.validate_authentication_header( context.webhook_headers, context.raw_event ), "Signature validation failed for event MESSAGE_SUBMIT for media message" @@ -332,7 +332,7 @@ def step_trigger_message_submit_text(context): @then('the header of the Conversation event "MESSAGE_SUBMIT" for a "text" message contains a valid signature') def step_signature_valid_message_submit_text(context): - assert context.conversation_webhooks.validate_authentication_header( + assert context.conversation_sinch_events.validate_authentication_header( context.webhook_headers, context.raw_event ), "Signature validation failed for event MESSAGE_SUBMIT for text message" diff --git a/tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request.py b/tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request.py index e60dea81..9d8dd8f8 100644 --- a/tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request.py +++ b/tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request.py @@ -39,6 +39,21 @@ def test_send_message_request_expects_accepts_processing_strategy(processing_str assert request.processing_strategy == processing_strategy +def test_send_message_request_serializes_event_destination_target_as_callback_url_for_api(): + """ + User-facing name is event_destination_target; JSON body uses callback_url. + """ + request = SendMessageRequest( + app_id="my-app-id", + recipient=Recipient(contact_id="my-contact-id"), + message=SendMessageRequestBody(text_message=TextMessage(text="Hello")), + event_destination_target="https://example.com/callback", + ) + payload = request.model_dump(mode="json", exclude_none=True, by_alias=True) + assert payload["callback_url"] == "https://example.com/callback" + assert "event_destination_target" not in payload + + @pytest.mark.parametrize("ttl_input,expected_serialized", [(10, "10s"), ("10s", "10s"), ("10", "10s"), (None, None)]) def test_send_message_request_expects_ttl_serialized_to_backend(ttl_input, expected_serialized): """ diff --git a/tests/unit/domains/conversation/v1/models/webhooks/events/test_conversation_webhooks_event_model.py b/tests/unit/domains/conversation/v1/models/sinch_events/events/test_conversation_sinch_event_model.py similarity index 91% rename from tests/unit/domains/conversation/v1/models/webhooks/events/test_conversation_webhooks_event_model.py rename to tests/unit/domains/conversation/v1/models/sinch_events/events/test_conversation_sinch_event_model.py index d062e0a0..fe5e5c13 100644 --- a/tests/unit/domains/conversation/v1/models/webhooks/events/test_conversation_webhooks_event_model.py +++ b/tests/unit/domains/conversation/v1/models/sinch_events/events/test_conversation_sinch_event_model.py @@ -1,8 +1,8 @@ -"""Unit tests for Conversation webhook event models.""" +"""Unit tests for Conversation Sinch Event models.""" import pytest -from sinch.domains.conversation.models.v1.webhooks import ( - ConversationWebhookEventBase, +from sinch.domains.conversation.models.v1.sinch_events import ( + ConversationSinchEventBase, MessageDeliveryReceiptEvent, MessageDeliveryReport, MessageInboundEvent, @@ -73,9 +73,9 @@ def test_message_submit_event_expects_parsed(): assert event.message_submit_notification.contact_id == "contact1" -def test_conversation_webhook_event_base_optional_fields(): +def test_conversation_sinch_event_base_optional_fields(): payload = {"app_id": "app1"} - event = ConversationWebhookEventBase(**payload) + event = ConversationSinchEventBase(**payload) assert event.app_id == "app1" assert event.project_id is None assert event.accepted_time is None diff --git a/tests/unit/domains/conversation/v1/webhooks/test_conversation_webhooks.py b/tests/unit/domains/conversation/v1/sinch_events/test_conversation_sinch_event.py similarity index 77% rename from tests/unit/domains/conversation/v1/webhooks/test_conversation_webhooks.py rename to tests/unit/domains/conversation/v1/sinch_events/test_conversation_sinch_event.py index f5e50212..16d416ca 100644 --- a/tests/unit/domains/conversation/v1/webhooks/test_conversation_webhooks.py +++ b/tests/unit/domains/conversation/v1/sinch_events/test_conversation_sinch_event.py @@ -1,10 +1,10 @@ -"""Unit tests for Conversation API webhooks (signature validation and parse_event).""" +"""Unit tests for Conversation API Sinch Events (signature validation and parse_event).""" from datetime import datetime, timezone import pytest -from sinch.domains.conversation.webhooks.v1 import ConversationWebhooks -from sinch.domains.conversation.models.v1.webhooks import ( +from sinch.domains.conversation.sinch_events.v1 import ConversationSinchEvent +from sinch.domains.conversation.models.v1.sinch_events import ( MessageDeliveryReceiptEvent, MessageInboundEvent, MessageSubmitEvent, @@ -12,13 +12,13 @@ @pytest.fixture -def webhook_secret(): +def callback_secret(): return "foo_secret1234" @pytest.fixture -def conversation_webhooks(webhook_secret): - return ConversationWebhooks(webhook_secret) +def conversation_sinch_event(callback_secret): + return ConversationSinchEvent(callback_secret) @pytest.fixture @@ -39,26 +39,26 @@ def sample_body(): } -def test_validate_authentication_header_valid_expects_true(conversation_webhooks, sample_body): - assert conversation_webhooks.validate_authentication_header( +def test_validate_authentication_header_valid_expects_true(conversation_sinch_event, sample_body): + assert conversation_sinch_event.validate_authentication_header( VALID_SIGNATURE_HEADERS, sample_body ) is True -def test_validate_authentication_header_missing_headers_expects_false(conversation_webhooks, sample_body): - assert conversation_webhooks.validate_authentication_header({}, sample_body) is False +def test_validate_authentication_header_missing_headers_expects_false(conversation_sinch_event, sample_body): + assert conversation_sinch_event.validate_authentication_header({}, sample_body) is False -def test_validate_authentication_header_invalid_signature_expects_false(conversation_webhooks, sample_body): +def test_validate_authentication_header_invalid_signature_expects_false(conversation_sinch_event, sample_body): headers = { "x-sinch-webhook-signature": "invalid", "x-sinch-webhook-signature-nonce": "01FJA8B4A7BM43YGWSG9GBV067", "x-sinch-webhook-signature-timestamp": "1634579353", } - assert conversation_webhooks.validate_authentication_header(headers, sample_body) is False + assert conversation_sinch_event.validate_authentication_header(headers, sample_body) is False -def test_parse_event_message_delivery_expects_message_delivery_receipt_event(conversation_webhooks): +def test_parse_event_message_delivery_expects_message_delivery_receipt_event(conversation_sinch_event): payload = { "app_id": "01EB37HMH1M6SV18BSNS3G135H", "project_id": "c36f3d3d-1513-2edd-ae42-11995557ff61", @@ -71,7 +71,7 @@ def test_parse_event_message_delivery_expects_message_delivery_receipt_event(con "contact_id": "01EXA07N79THJ20WSN6AS30TMW", }, } - event = conversation_webhooks.parse_event(payload) + event = conversation_sinch_event.parse_event(payload) assert isinstance(event, MessageDeliveryReceiptEvent) assert event.message_delivery_report is not None assert event.message_delivery_report.message_id == "01EQBC1A3BEK731GY4YXEN0C2R" @@ -79,7 +79,7 @@ def test_parse_event_message_delivery_expects_message_delivery_receipt_event(con assert event.accepted_time == datetime(2020, 11, 17, 15, 9, 11, 659000, tzinfo=timezone.utc) -def test_parse_event_message_inbound_expects_message_inbound_event(conversation_webhooks): +def test_parse_event_message_inbound_expects_message_inbound_event(conversation_sinch_event): payload = { "app_id": "01EB37HMH1M6SV18BSNS3G135H", "project_id": "c36f3d3d-1513-2edd-ae42-11995557ff61", @@ -90,7 +90,7 @@ def test_parse_event_message_inbound_expects_message_inbound_event(conversation_ "channel_identity": {"channel": "WHATSAPP", "identity": "1234567890"}, }, } - event = conversation_webhooks.parse_event(payload) + event = conversation_sinch_event.parse_event(payload) assert isinstance(event, MessageInboundEvent) assert event.message is not None assert event.message.contact_id == "01EXA07N79THJ20WSN6AS30TMW" @@ -99,7 +99,7 @@ def test_parse_event_message_inbound_expects_message_inbound_event(conversation_ assert event.message.contact_message.text_message.text == "Hello" -def test_parse_event_message_submit_expects_message_submit_event(conversation_webhooks): +def test_parse_event_message_submit_expects_message_submit_event(conversation_sinch_event): payload = { "app_id": "01EB37HMH1M6SV18BSNS3G135H", "project_id": "c36f3d3d-1513-2edd-ae42-11995557ff61", @@ -110,20 +110,20 @@ def test_parse_event_message_submit_expects_message_submit_event(conversation_we "submitted_message": {"text_message": {"text": "Hi"}}, }, } - event = conversation_webhooks.parse_event(payload) + event = conversation_sinch_event.parse_event(payload) assert isinstance(event, MessageSubmitEvent) assert event.message_submit_notification is not None assert event.message_submit_notification.contact_id == "01EXA07N79THJ20WSN6AS30TMW" -def test_parse_event_json_string_expects_parsed(conversation_webhooks): +def test_parse_event_json_string_expects_parsed(conversation_sinch_event): payload_str = '{"app_id":"app1","message_delivery_report":{"status":"DELIVERED"}}' - event = conversation_webhooks.parse_event(payload_str) + event = conversation_sinch_event.parse_event(payload_str) assert isinstance(event, MessageDeliveryReceiptEvent) assert event.app_id == "app1" assert event.message_delivery_report.status == "DELIVERED" -def test_parse_event_invalid_json_expects_value_error(conversation_webhooks): +def test_parse_event_invalid_json_expects_value_error(conversation_sinch_event): with pytest.raises(ValueError, match="Failed to decode JSON"): - conversation_webhooks.parse_event("not json") + conversation_sinch_event.parse_event("not json") diff --git a/tests/unit/domains/conversation/v1/utils/test_message_helpers.py b/tests/unit/domains/conversation/v1/utils/test_message_helpers.py index ad6875f6..1674b68e 100644 --- a/tests/unit/domains/conversation/v1/utils/test_message_helpers.py +++ b/tests/unit/domains/conversation/v1/utils/test_message_helpers.py @@ -120,9 +120,9 @@ class TestSplitSendKwargs: {}, ), ( - {"ttl": 10, "callback_url": "https://example.com/callback"}, + {"ttl": 10, "event_destination_target": "https://example.com/callback"}, {}, - {"ttl": 10, "callback_url": "https://example.com/callback"}, + {"ttl": 10, "event_destination_target": "https://example.com/callback"}, ), ( { From 7fbee16c1a83c3f1e74ad14afe29100307d9a08e Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Tue, 24 Mar 2026 16:18:32 +0100 Subject: [PATCH 02/10] update gitignore --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3de3edbd..f17ea8a2 100644 --- a/.gitignore +++ b/.gitignore @@ -138,4 +138,7 @@ poetry.lock # .DS_Store files .DS_Store -qodana.yaml \ No newline at end of file +qodana.yaml + +# AI stuff +.claude \ No newline at end of file From b669727670ed0c9f65f1a111fbb922e07abf06cc Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Tue, 24 Mar 2026 16:23:38 +0100 Subject: [PATCH 03/10] update auth validation --- .../authentication/webhooks/v1/authentication_validation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sinch/domains/authentication/webhooks/v1/authentication_validation.py b/sinch/domains/authentication/webhooks/v1/authentication_validation.py index 304bde67..7d224216 100644 --- a/sinch/domains/authentication/webhooks/v1/authentication_validation.py +++ b/sinch/domains/authentication/webhooks/v1/authentication_validation.py @@ -33,7 +33,7 @@ def validate_signature_header( return False expected_signature = compute_hmac_signature(body, callback_secret) - return signature == expected_signature + return hmac.compare_digest(signature, expected_signature) def normalize_headers(headers: Dict[str, str]) -> Dict[str, str]: From 051211cc14f8855a437eb2f36aa0786df8affc7a Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Tue, 24 Mar 2026 16:45:17 +0100 Subject: [PATCH 04/10] update MIGRATION_GUIDE.md --- MIGRATION_GUIDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index aff85db8..b2d5dc74 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -130,7 +130,7 @@ The Conversation domain API access remains `sinch_client.conversation`; message | Old | New | |-----|-----| -| `sinch_client.conversation.webhook` (REST: create, list, get, update, delete webhooks; models under `sinch.domains.conversation.models.webhook`, e.g. `CreateConversationWebhookRequest`, `SinchListWebhooksResponse`) | Event Destinations REST for Conversation when added to the V2 SDK (naming aligns with `EventDestination`, list responses use `event_destinations`, …). | +| `sinch_client.conversation.webhook` (REST: create, list, get, update, delete webhooks; models under `sinch.domains.conversation.models.webhook`, e.g. `CreateConversationWebhookRequest`, `SinchListWebhooksResponse`) | **Not available in V2.** The Conversation client only exposes `messages` and `sinch_events`; More features are planned for future releases. To validate and parse inbound Sinch Events payloads, use `sinch_client.conversation.sinch_events(callback_secret)`—see **Sinch Events** below. | #### Sinch Events (Event Destinations payload models and package path) @@ -140,7 +140,7 @@ The Conversation domain API access remains `sinch_client.conversation`; message | — | [`ConversationSinchEvent`](sinch/domains/conversation/sinch_events/v1/conversation_sinch_event.py) (handler: signature validation and `parse_event`) | | — | `ConversationSinchEventPayload`, `ConversationSinchEventBase`, and concrete event types (e.g. `MessageInboundEvent`, `MessageDeliveryReceiptEvent`, `MessageSubmitEvent`) | -To obtain a Conversation Sinch Events handler: `sinch_client.conversation.sinch_events(callback_secret)` returns a [`ConversationSinchEvent`](sinch/domains/conversation/sinch_events/v1/conversation_sinch_event.py) instance; `handler.parse_event(request_body)` returns a `ConversationSinchEventPayload` (or a concrete event type). +To obtain a Conversation Sinch Events handler: `sinch_client.conversation.sinch_events(callback_secret)` returns a [`ConversationSinchEvent`](sinch/domains/conversation/sinch_events/v1/conversation_sinch_event.py) instance; `handler.parse_event(request_body)` returns a `ConversationSinchEventPayload`. ```python # New From 6715385f8ed59eb3a5b64b2c2d85e08fe668ce46 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Tue, 24 Mar 2026 19:23:08 +0100 Subject: [PATCH 05/10] Update examples/sinch_events/README.md Co-authored-by: Jean-Pierre Portier <141755467+JPPortier@users.noreply.github.com> --- examples/sinch_events/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/sinch_events/README.md b/examples/sinch_events/README.md index 7547fb3f..1a4991f7 100644 --- a/examples/sinch_events/README.md +++ b/examples/sinch_events/README.md @@ -82,7 +82,7 @@ The server exposes the following endpoints: ## Using ngrok to expose your local server -To test your webhook locally, you can tunnel requests to your local server using ngrok. +To test your "Sinch Events" processing locally, you can tunnel requests to your local server using ngrok. *Note: The default port is `3001`, but this can be changed (see [Server port](#Configuration))* From bdbc3c1a6d7fa1e9cee467287e5994afee1e6995 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Tue, 24 Mar 2026 19:23:14 +0100 Subject: [PATCH 06/10] Update examples/sinch_events/README.md Co-authored-by: Jean-Pierre Portier <141755467+JPPortier@users.noreply.github.com> --- examples/sinch_events/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/sinch_events/README.md b/examples/sinch_events/README.md index 1a4991f7..fc6715f3 100644 --- a/examples/sinch_events/README.md +++ b/examples/sinch_events/README.md @@ -109,5 +109,5 @@ Use this value to configure the Sinch Events URLs: You can also set these Sinch Events URLs in the Sinch dashboard; the API parameters above override the default values configured there. > **Note**: If you have set a Sinch Event secret (e.g., `SMS_SINCH_EVENT_SECRET`), the Sinch Event URL must be configured in the Sinch dashboard -> and cannot be overridden via API parameters. The Sinch Event secret is used to validate incoming webhook requests, +> and cannot be overridden via API parameters. The Sinch Event secret is used to validate incoming Sinch Events requests, > and the URL associated with it must be set in the dashboard. From b1c08472e21c20e5f1958c79c588367149be2c65 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Tue, 24 Mar 2026 19:23:22 +0100 Subject: [PATCH 07/10] Update examples/sinch_events/README.md Co-authored-by: Jean-Pierre Portier <141755467+JPPortier@users.noreply.github.com> --- examples/sinch_events/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/sinch_events/README.md b/examples/sinch_events/README.md index fc6715f3..1523eb86 100644 --- a/examples/sinch_events/README.md +++ b/examples/sinch_events/README.md @@ -1,7 +1,7 @@ # Sinch Events Handlers for Sinch Python SDK This directory contains a server application built with [Sinch Python SDK](https://github.com/sinch/sinch-sdk-python) -to process incoming webhooks from Sinch services. +to process incoming events from Sinch services. The Sinch Events Handlers are organized by service: - **SMS**: Handlers for SMS events (`sms_api/`) From cc54e911fdb81b115bfdf6515f457c4a9132c610 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Tue, 24 Mar 2026 19:23:45 +0100 Subject: [PATCH 08/10] Update sinch/domains/authentication/webhooks/v1/authentication_validation.py Co-authored-by: Jean-Pierre Portier <141755467+JPPortier@users.noreply.github.com> --- .../authentication/webhooks/v1/authentication_validation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sinch/domains/authentication/webhooks/v1/authentication_validation.py b/sinch/domains/authentication/webhooks/v1/authentication_validation.py index 7d224216..1df32e4b 100644 --- a/sinch/domains/authentication/webhooks/v1/authentication_validation.py +++ b/sinch/domains/authentication/webhooks/v1/authentication_validation.py @@ -65,7 +65,7 @@ def get_header(header_value: Optional[Union[str, List[str]]]) -> Optional[str]: return header_value -def validate_webhook_signature_with_nonce( +def validate_sinch_event_signature_with_nonce( callback_secret: str, headers: Dict[str, str], body: str From 1a55eae007f09e4eecafc8ce9a2ea80e8c2e87dd Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Tue, 24 Mar 2026 19:24:01 +0100 Subject: [PATCH 09/10] Update sinch/domains/authentication/webhooks/v1/authentication_validation.py Co-authored-by: Jean-Pierre Portier <141755467+JPPortier@users.noreply.github.com> --- .../authentication/webhooks/v1/authentication_validation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sinch/domains/authentication/webhooks/v1/authentication_validation.py b/sinch/domains/authentication/webhooks/v1/authentication_validation.py index 1df32e4b..df32f0bf 100644 --- a/sinch/domains/authentication/webhooks/v1/authentication_validation.py +++ b/sinch/domains/authentication/webhooks/v1/authentication_validation.py @@ -71,7 +71,7 @@ def validate_sinch_event_signature_with_nonce( body: str ) -> bool: """ - Validate signature headers for webhook callbacks that use nonce and timestamp. + Validate signature headers for Sinch Event callbacks that use nonce and timestamp. :param callback_secret: Secret associated with the webhook. :type callback_secret: str From 3aed8cec83c1c920b7302e6db82632e4851cc727 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Tue, 24 Mar 2026 21:59:10 +0100 Subject: [PATCH 10/10] code review comments --- .../send_handle_incoming_sms/README.md | 2 +- examples/sinch_events/pyproject.toml | 2 +- .../{webhooks => sinch_events}/__init__.py | 0 .../{webhooks => sinch_events}/v1/__init__.py | 0 .../v1/authentication_validation.py | 22 +++++++++---------- .../v1/sinch_event_utils.py} | 0 .../v1/conversation_sinch_event.py | 8 +++---- .../numbers/sinch_events/v1/sinch_events.py | 4 ++-- .../v1/response/recipient_delivery_report.py | 2 +- .../sms/sinch_events/v1/sms_sinch_event.py | 8 +++---- .../test_authentication_validation.py | 4 +++- ...ook_utils.py => test_sinch_event_utils.py} | 2 +- 12 files changed, 28 insertions(+), 26 deletions(-) rename sinch/domains/authentication/{webhooks => sinch_events}/__init__.py (100%) rename sinch/domains/authentication/{webhooks => sinch_events}/v1/__init__.py (100%) rename sinch/domains/authentication/{webhooks => sinch_events}/v1/authentication_validation.py (88%) rename sinch/domains/authentication/{webhooks/v1/webhook_utils.py => sinch_events/v1/sinch_event_utils.py} (100%) rename tests/unit/domains/authentication/{test_webhook_utils.py => test_sinch_event_utils.py} (97%) diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/README.md b/examples/getting-started/conversation/send_handle_incoming_sms/README.md index b9a5e5e4..4e091b73 100644 --- a/examples/getting-started/conversation/send_handle_incoming_sms/README.md +++ b/examples/getting-started/conversation/send_handle_incoming_sms/README.md @@ -70,7 +70,7 @@ The server listens on the port set in your `.env` file (default: 3001). ### Exposing the server with ngrok -To receive webhooks on your machine, expose the server with a tunnel (e.g. ngrok). +To receive Conversation API Sinch Events on your machine, expose the server with a tunnel (e.g. ngrok). ```bash diff --git a/examples/sinch_events/pyproject.toml b/examples/sinch_events/pyproject.toml index 76a53090..206757a7 100644 --- a/examples/sinch_events/pyproject.toml +++ b/examples/sinch_events/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "sinch-sdk-python-quickstart-server" version = "0.1.0" -description = "Sinch SDK Python Quickstart Webhooks Server" +description = "Sinch SDK Python Quickstart Sinch Events Server" readme = "README.md" package-mode = false diff --git a/sinch/domains/authentication/webhooks/__init__.py b/sinch/domains/authentication/sinch_events/__init__.py similarity index 100% rename from sinch/domains/authentication/webhooks/__init__.py rename to sinch/domains/authentication/sinch_events/__init__.py diff --git a/sinch/domains/authentication/webhooks/v1/__init__.py b/sinch/domains/authentication/sinch_events/v1/__init__.py similarity index 100% rename from sinch/domains/authentication/webhooks/v1/__init__.py rename to sinch/domains/authentication/sinch_events/v1/__init__.py diff --git a/sinch/domains/authentication/webhooks/v1/authentication_validation.py b/sinch/domains/authentication/sinch_events/v1/authentication_validation.py similarity index 88% rename from sinch/domains/authentication/webhooks/v1/authentication_validation.py rename to sinch/domains/authentication/sinch_events/v1/authentication_validation.py index df32f0bf..20a16ca4 100644 --- a/sinch/domains/authentication/webhooks/v1/authentication_validation.py +++ b/sinch/domains/authentication/sinch_events/v1/authentication_validation.py @@ -73,18 +73,18 @@ def validate_sinch_event_signature_with_nonce( """ Validate signature headers for Sinch Event callbacks that use nonce and timestamp. - :param callback_secret: Secret associated with the webhook. + :param callback_secret: Secret associated with the Sinch Event destination. :type callback_secret: str :param headers: Incoming request's headers. :type headers: Dict[str, str] :param body: Incoming request's body. :type body: str - :returns: True if the X-Sinch-Webhook-Signature header is valid. + :returns: True if the ``X-Sinch-Webhook-Signature`` header and related nonce/timestamp headers are valid. :rtype: bool """ if callback_secret is None: return False - + normalized_headers = normalize_headers(headers) signature = get_header(normalized_headers.get('x-sinch-webhook-signature')) if signature is None: @@ -92,7 +92,7 @@ def validate_sinch_event_signature_with_nonce( nonce = get_header(normalized_headers.get('x-sinch-webhook-signature-nonce')) timestamp = get_header(normalized_headers.get('x-sinch-webhook-signature-timestamp')) - + if nonce is None or timestamp is None: return False @@ -101,24 +101,24 @@ def validate_sinch_event_signature_with_nonce( body_as_string = json.dumps(body) signed_data = compute_signed_data(body_as_string, nonce, timestamp) - - expected_signature = calculate_webhook_signature(signed_data, callback_secret) + + expected_signature = calculate_sinch_event_signature(signed_data, callback_secret) return hmac.compare_digest(signature, expected_signature) def compute_signed_data(body: str, nonce: str, timestamp: str) -> str: """ - Compute signed data for webhook signature validation. - + Compute signed data for Sinch Event signature validation. + Format: body.nonce.timestamp (with dots as separators) """ return f'{body}.{nonce}.{timestamp}' -def calculate_webhook_signature(signed_data: str, secret: str) -> str: +def calculate_sinch_event_signature(signed_data: str, secret: str) -> str: """ - Calculate webhook signature using HMAC-SHA256 with Base64 encoding. - + Calculate Sinch Event signature using HMAC-SHA256 with Base64 encoding. + :param signed_data: The data to sign (body.nonce.timestamp) :type signed_data: str :param secret: The secret key for HMAC diff --git a/sinch/domains/authentication/webhooks/v1/webhook_utils.py b/sinch/domains/authentication/sinch_events/v1/sinch_event_utils.py similarity index 100% rename from sinch/domains/authentication/webhooks/v1/webhook_utils.py rename to sinch/domains/authentication/sinch_events/v1/sinch_event_utils.py diff --git a/sinch/domains/conversation/sinch_events/v1/conversation_sinch_event.py b/sinch/domains/conversation/sinch_events/v1/conversation_sinch_event.py index 5e205f70..45acda9b 100644 --- a/sinch/domains/conversation/sinch_events/v1/conversation_sinch_event.py +++ b/sinch/domains/conversation/sinch_events/v1/conversation_sinch_event.py @@ -1,9 +1,9 @@ import logging from typing import Any, Dict, Union, Optional -from sinch.domains.authentication.webhooks.v1.authentication_validation import ( - validate_webhook_signature_with_nonce, +from sinch.domains.authentication.sinch_events.v1.authentication_validation import ( + validate_sinch_event_signature_with_nonce, ) -from sinch.domains.authentication.webhooks.v1.webhook_utils import ( +from sinch.domains.authentication.sinch_events.v1.sinch_event_utils import ( decode_payload, parse_json, normalize_iso_timestamp, @@ -57,7 +57,7 @@ def _validate_signature( if not secret: return False payload_str = decode_payload(payload, headers) - return validate_webhook_signature_with_nonce( + return validate_sinch_event_signature_with_nonce( secret, headers, payload_str ) diff --git a/sinch/domains/numbers/sinch_events/v1/sinch_events.py b/sinch/domains/numbers/sinch_events/v1/sinch_events.py index d591acbe..a93708a5 100644 --- a/sinch/domains/numbers/sinch_events/v1/sinch_events.py +++ b/sinch/domains/numbers/sinch_events/v1/sinch_events.py @@ -1,8 +1,8 @@ from typing import Any, Dict, Optional, Union -from sinch.domains.authentication.webhooks.v1.authentication_validation import ( +from sinch.domains.authentication.sinch_events.v1.authentication_validation import ( validate_signature_header, ) -from sinch.domains.authentication.webhooks.v1.webhook_utils import ( +from sinch.domains.authentication.sinch_events.v1.sinch_event_utils import ( decode_payload, parse_json, normalize_iso_timestamp, diff --git a/sinch/domains/sms/models/v1/response/recipient_delivery_report.py b/sinch/domains/sms/models/v1/response/recipient_delivery_report.py index 91d4aa59..ddc605ce 100644 --- a/sinch/domains/sms/models/v1/response/recipient_delivery_report.py +++ b/sinch/domains/sms/models/v1/response/recipient_delivery_report.py @@ -16,7 +16,7 @@ from sinch.domains.sms.models.v1.internal.base import ( BaseModelConfigurationResponse, ) -from sinch.domains.authentication.webhooks.v1.webhook_utils import ( +from sinch.domains.authentication.sinch_events.v1.sinch_event_utils import ( normalize_iso_timestamp, ) diff --git a/sinch/domains/sms/sinch_events/v1/sms_sinch_event.py b/sinch/domains/sms/sinch_events/v1/sms_sinch_event.py index 8f8daee9..03f52892 100644 --- a/sinch/domains/sms/sinch_events/v1/sms_sinch_event.py +++ b/sinch/domains/sms/sinch_events/v1/sms_sinch_event.py @@ -1,10 +1,10 @@ import json from typing import Any, Dict, Union, Optional from pydantic import TypeAdapter -from sinch.domains.authentication.webhooks.v1.authentication_validation import ( - validate_webhook_signature_with_nonce, +from sinch.domains.authentication.sinch_events.v1.authentication_validation import ( + validate_sinch_event_signature_with_nonce, ) -from sinch.domains.authentication.webhooks.v1.webhook_utils import ( +from sinch.domains.authentication.sinch_events.v1.sinch_event_utils import ( decode_payload, parse_json, normalize_iso_timestamp, @@ -56,7 +56,7 @@ def validate_authentication_header( if isinstance(json_payload, bytes) else json_payload ) - return validate_webhook_signature_with_nonce( + return validate_sinch_event_signature_with_nonce( self.app_secret, headers, payload_str ) diff --git a/tests/unit/domains/authentication/test_authentication_validation.py b/tests/unit/domains/authentication/test_authentication_validation.py index b8f11c86..79cfddc5 100644 --- a/tests/unit/domains/authentication/test_authentication_validation.py +++ b/tests/unit/domains/authentication/test_authentication_validation.py @@ -1,5 +1,7 @@ import pytest -from sinch.domains.authentication.webhooks.v1.authentication_validation import validate_signature_header +from sinch.domains.authentication.sinch_events.v1.authentication_validation import ( + validate_signature_header, +) @pytest.fixture diff --git a/tests/unit/domains/authentication/test_webhook_utils.py b/tests/unit/domains/authentication/test_sinch_event_utils.py similarity index 97% rename from tests/unit/domains/authentication/test_webhook_utils.py rename to tests/unit/domains/authentication/test_sinch_event_utils.py index 059b4416..555b6567 100644 --- a/tests/unit/domains/authentication/test_webhook_utils.py +++ b/tests/unit/domains/authentication/test_sinch_event_utils.py @@ -1,6 +1,6 @@ import pytest from datetime import datetime, timezone -from sinch.domains.authentication.webhooks.v1.webhook_utils import ( +from sinch.domains.authentication.sinch_events.v1.sinch_event_utils import ( parse_json, normalize_iso_timestamp, )