Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ jobs:
cp sinch-sdk-mockserver/features/sms/webhooks.feature ./tests/e2e/sms/features/
cp sinch-sdk-mockserver/features/number-lookup/lookups.feature ./tests/e2e/number-lookup/features/
cp sinch-sdk-mockserver/features/conversation/messages.feature ./tests/e2e/conversation/features/
cp sinch-sdk-mockserver/features/conversation/webhooks-events.feature ./tests/e2e/conversation/features/

- name: Wait for mock server
run: .github/scripts/wait-for-mockserver.sh
Expand Down
4 changes: 3 additions & 1 deletion examples/webhooks/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ SERVER_PORT =
# See https://developers.sinch.com/docs/numbers/api-reference/numbers/tag/Numbers-Callbacks/
NUMBERS_WEBHOOKS_SECRET = NUMBERS_WEBHOOKS_SECRET
# See https://developers.sinch.com/docs/sms/api-reference/sms/tag/Webhooks/#tag/Webhooks/section/Callbacks
SMS_WEBHOOKS_SECRET = SMS_WEBHOOKS_SECRET
SMS_WEBHOOKS_SECRET = SMS_WEBHOOKS_SECRET
# See https://developers.sinch.com/docs/conversation/callbacks
CONVERSATION_WEBHOOKS_SECRET = CONVERSATION_WEBHOOKS_SECRET
18 changes: 13 additions & 5 deletions examples/webhooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ to process incoming webhooks from Sinch services.
The webhook handlers are organized by service:
- **SMS**: Handlers for SMS webhook events (`sms_api/`)
- **Numbers**: Handlers for Numbers API webhook events (`numbers_api/`)
- **Conversation**: Handlers for Conversation API webhook events (`conversation_api/`)

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

Expand Down Expand Up @@ -39,6 +40,10 @@ This directory contains both the webhook handlers and the server application (`s
```
SMS_WEBHOOKS_SECRET=Your Sinch SMS Webhook 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_WEBHOOKS_SECRET=Your Conversation Webhook Secret
```

## Usage

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

The server exposes the following endpoints:

| Service | Endpoint |
|--------------|--------------------|
| Numbers | /NumbersEvent |
| SMS | /SmsEvent |
| Service | Endpoint |
|--------------|----------------------|
| Numbers | /NumbersEvent |
| SMS | /SmsEvent |
| Conversation | /ConversationEvent |

## Using ngrok to expose your local server

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

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

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

Expand Down
Empty file.
52 changes: 52 additions & 0 deletions examples/webhooks/conversation_api/controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import re
from flask import request, Response
from webhooks.conversation_api.server_business_logic import handle_conversation_event


def _charset_from_content_type(content_type):
"""Extract charset from Content-Type header; default to utf-8 if missing."""
if not content_type:
return "utf-8"
match = re.search(r"charset\s*=\s*([^\s;]+)", content_type, re.I)
return match.group(1).strip("'\"").lower() if match else "utf-8"


def _decode_body(raw_body, content_type):
"""Decode request body using Content-Type charset, fallback to utf-8."""
if not raw_body:
return ""
charset = _charset_from_content_type(content_type)
try:
return raw_body.decode(charset)
except (LookupError, UnicodeDecodeError):
return raw_body.decode("utf-8")


class ConversationController:
def __init__(self, sinch_client, webhooks_secret):
self.sinch_client = sinch_client
self.webhooks_secret = webhooks_secret
self.logger = self.sinch_client.configuration.logger

def conversation_event(self):
headers = dict(request.headers)
raw_body = request.raw_body if request.raw_body else b""
content_type = headers.get("Content-Type") or headers.get("content-type") or ""
body_str = _decode_body(raw_body, content_type)

webhooks_service = self.sinch_client.conversation.webhooks(self.webhooks_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(
headers=headers,
json_payload=body_str,
)
if not valid:
return Response(status=401)

event = webhooks_service.parse_event(body_str)
handle_conversation_event(event=event, logger=self.logger)

return Response(status=200)
81 changes: 81 additions & 0 deletions examples/webhooks/conversation_api/server_business_logic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
from sinch.domains.conversation.models.v1.webhooks import (
ConversationWebhookEventBase,
MessageDeliveryReceiptEvent,
MessageInboundEvent,
MessageSubmitEvent,
)


def handle_conversation_event(event: ConversationWebhookEventBase, logger):
"""
Dispatch a Conversation webhook event to the appropriate handler by trigger type.

:param event: Parsed webhook event (MessageDeliveryReceiptEvent, MessageInboundEvent, etc.).
:param logger: Logger instance for output.
"""
if isinstance(event, MessageInboundEvent):
_handle_message_inbound(event, logger)
elif isinstance(event, MessageDeliveryReceiptEvent):
_handle_message_delivery(event, logger)
elif isinstance(event, MessageSubmitEvent):
_handle_message_submit(event, logger)
else:
logger.debug("Event: %s", event.model_dump_json(indent=2) if hasattr(event, "model_dump_json") else event)


def _handle_message_inbound(event: MessageInboundEvent, logger):
"""Handle MESSAGE_INBOUND: log inbound message."""
logger.info("## MESSAGE_INBOUND")
msg = event.message
contact_msg = msg.contact_message
channel_identity = msg.channel_identity
contact_id = msg.contact_id
channel = channel_identity.channel if channel_identity else "?"
identity = channel_identity.identity if channel_identity else "?"
logger.info(
"A new message has been received on the channel '%s' (identity: %s) from the contact ID '%s'",
channel,
identity,
contact_id,
)
if contact_msg:
if hasattr(contact_msg, "text_message") and contact_msg.text_message:
logger.info("Text: %s", contact_msg.text_message.text)
elif hasattr(contact_msg, "media_message") and contact_msg.media_message:
logger.info("Media: %s", getattr(contact_msg.media_message, "url", contact_msg.media_message))
elif hasattr(contact_msg, "fallback_message") and contact_msg.fallback_message:
logger.info("Fallback: %s", contact_msg.fallback_message)
else:
logger.info("Contact message: %s", contact_msg)


def _handle_message_delivery(event: MessageDeliveryReceiptEvent, logger):
"""Handle MESSAGE_DELIVERY: log delivery status and failure reason if failed."""
logger.info("## MESSAGE_DELIVERY")
report = event.message_delivery_report
status = report.status
logger.info("Message delivery status: '%s'", status)
if status == "FAILED" and report.reason:
logger.info(
"Reason: %s (%s) - %s",
report.reason.code,
getattr(report.reason, "sub_code", ""),
report.reason.description,
)


def _handle_message_submit(event: MessageSubmitEvent, logger):
"""Handle MESSAGE_SUBMIT: log that the message was submitted to the channel."""
logger.info("## MESSAGE_SUBMIT")
submit_notification = event.message_submit_notification
channel_identity = submit_notification.channel_identity
channel = channel_identity.channel if channel_identity else "?"
identity = channel_identity.identity if channel_identity else "?"
logger.info(
"The following message has been submitted on the channel '%s' (identity: %s) to the contact ID '%s'",
channel,
identity,
submit_notification.contact_id,
)
if submit_notification.submitted_message:
logger.debug("Submitted message: %s", submit_notification.submitted_message)
4 changes: 4 additions & 0 deletions examples/webhooks/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from flask import Flask, request
from webhooks.numbers_api.controller import NumbersController
from webhooks.sms_api.controller import SmsController
from webhooks.conversation_api.controller import ConversationController
from webhooks.sinch_client_helper import get_sinch_client, load_config

app = Flask(__name__)
Expand All @@ -18,6 +19,7 @@
port = int(config.get('SERVER_PORT') or 3001)
numbers_webhooks_secret = config.get('NUMBERS_WEBHOOKS_SECRET')
sms_webhooks_secret = config.get('SMS_WEBHOOKS_SECRET')
conversation_webhooks_secret = config.get('CONVERSATION_WEBHOOKS_SECRET')
sinch_client = get_sinch_client(config)

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

numbers_controller = NumbersController(sinch_client, numbers_webhooks_secret)
sms_controller = SmsController(sinch_client, sms_webhooks_secret)
conversation_controller = ConversationController(sinch_client, conversation_webhooks_secret or '')


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

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

if __name__ == '__main__':
app.run(port=port)
12 changes: 12 additions & 0 deletions sinch/domains/conversation/conversation.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from sinch.domains.conversation.api.v1 import (
Messages,
)
from sinch.domains.conversation.webhooks.v1 import ConversationWebhooks


class Conversation:
Expand All @@ -12,3 +13,14 @@ class Conversation:
def __init__(self, sinch):
self._sinch = sinch
self.messages = Messages(self._sinch)

def webhooks(self, callback_secret: str) -> ConversationWebhooks:
"""
Create a Conversation API webhooks handler with the given webhook secret.

:param callback_secret: Secret used for webhook signature validation.
:type callback_secret: str
:returns: A configured webhooks handler.
:rtype: ConversationWebhooks
"""
return ConversationWebhooks(callback_secret)
23 changes: 23 additions & 0 deletions sinch/domains/conversation/models/v1/webhooks/__init__.py
Copy link

@JPPortier JPPortier Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed, moved under models/

Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from sinch.domains.conversation.models.v1.webhooks.events import (
ConversationWebhookEvent,
ConversationWebhookEventBase,
InboundMessage,
MessageDeliveryReceiptEvent,
MessageDeliveryReport,
MessageDeliveryStatusType,
MessageInboundEvent,
MessageSubmitEvent,
MessageSubmitNotification,
)

__all__ = [
"ConversationWebhookEvent",
"ConversationWebhookEventBase",
"InboundMessage",
"MessageDeliveryReceiptEvent",
"MessageDeliveryReport",
"MessageDeliveryStatusType",
"MessageInboundEvent",
"MessageSubmitEvent",
"MessageSubmitNotification",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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.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_delivery_status_type import (
MessageDeliveryStatusType,
)
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",
"MessageDeliveryStatusType",
"MessageInboundEvent",
"MessageSubmitEvent",
"MessageSubmitNotification",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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,
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from datetime import datetime
from typing import Optional

from pydantic import Field, StrictStr

from sinch.domains.conversation.webhooks.v1.internal import WebhookEvent


class ConversationWebhookEventBase(WebhookEvent):
"""Base fields present on every Conversation API webhook payload."""

app_id: Optional[StrictStr] = Field(
default=None,
description="Id of the subscribed app.",
)
project_id: Optional[StrictStr] = Field(
default=None,
description="The project ID of the app which has subscribed for the callback.",
)
accepted_time: Optional[datetime] = Field(
default=None,
description="Timestamp when the channel callback was accepted by the Conversation API.",
)
event_time: Optional[datetime] = Field(
default=None,
description="Timestamp of the event as provided by the underlying channels.",
)
message_metadata: Optional[StrictStr] = Field(
default=None,
description="Context-dependent metadata.",
)
correlation_id: Optional[StrictStr] = Field(
default=None,
description="Value from correlation_id of the send message request.",
)
Loading