Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,7 @@ poetry.lock
# .DS_Store files
.DS_Store

qodana.yaml
qodana.yaml

# AI stuff
.claude
32 changes: 32 additions & 0 deletions MIGRATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) | **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)

| 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`.

```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).

<br>

### [`SMS`](https://github.com/sinch/sinch-sdk-python/tree/main/sinch/domains/sms)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions examples/sinch_events/README.md
Original file line number Diff line number Diff line change
@@ -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/`)
Expand Down Expand Up @@ -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
```
Expand Down Expand Up @@ -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))*

Expand All @@ -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.
6 changes: 3 additions & 3 deletions examples/sinch_events/conversation_api/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
2 changes: 1 addition & 1 deletion examples/sinch_events/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -65,34 +65,34 @@ 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
) -> 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.
: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:
return False

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

Expand All @@ -101,24 +101,24 @@ def validate_webhook_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
Expand Down
Loading
Loading