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
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.
30 changes: 30 additions & 0 deletions examples/webhooks/conversation_api/controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from flask import request, Response
from webhooks.conversation_api.server_business_logic import handle_conversation_event


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""

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=raw_body,
)
if not valid:
return Response(status=401)

event = webhooks_service.parse_event(raw_body, headers)
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)
6 changes: 3 additions & 3 deletions examples/webhooks/numbers_api/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,21 @@ def __init__(self, sinch_client, webhooks_secret):

def numbers_event(self):
headers = dict(request.headers)
body_str = request.raw_body.decode('utf-8') if request.raw_body else ''
raw_body = request.raw_body if request.raw_body else b""

webhooks_service = self.sinch_client.numbers.webhooks(self.webhooks_secret)

ensure_valid_authentication = False
if ensure_valid_authentication:
valid_auth = webhooks_service.validate_authentication_header(
headers=headers,
json_payload=body_str
json_payload=raw_body,
)

if not valid_auth:
return Response(status=401)

event = webhooks_service.parse_event(body_str)
event = webhooks_service.parse_event(raw_body, headers)

handle_numbers_event(numbers_event=event, logger=self.logger)

Expand Down
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)
7 changes: 3 additions & 4 deletions examples/webhooks/sms_api/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ def __init__(self, sinch_client, webhooks_secret):

def sms_event(self):
headers = dict(request.headers)

body_str = request.raw_body.decode('utf-8') if request.raw_body else ''
raw_body = request.raw_body if request.raw_body else b""

webhooks_service = self.sinch_client.sms.webhooks(self.webhooks_secret)

Expand All @@ -24,13 +23,13 @@ def sms_event(self):
if ensure_valid_authentication:
valid_auth = webhooks_service.validate_authentication_header(
headers=headers,
json_payload=body_str
json_payload=raw_body,
)

if not valid_auth:
return Response(status=401)

event = webhooks_service.parse_event(body_str)
event = webhooks_service.parse_event(raw_body, headers)

handle_sms_event(sms_event=event, logger=self.logger)

Expand Down
38 changes: 37 additions & 1 deletion sinch/domains/authentication/webhooks/v1/webhook_utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,43 @@
import json
import re
from datetime import datetime
from typing import Any, Dict
from typing import Any, Dict, Optional, Union


def _content_type_from_headers(headers: Optional[Dict[str, str]]) -> str:
"""Get Content-Type from headers dict (case-insensitive)."""
if not headers:
return ""
return headers.get("content-type") or headers.get("Content-Type") or ""


def _charset_from_content_type(content_type: str) -> str:
"""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_payload(
payload: Union[str, bytes], headers: Optional[Dict[str, str]] = None
) -> str:
"""
Decode request body to str using Content-Type charset when payload is bytes.
When payload is str, return as-is. When bytes, use charset from headers
(default utf-8);
"""
if isinstance(payload, str):
return payload
if not payload:
return ""
content_type = _content_type_from_headers(headers)
charset = _charset_from_content_type(content_type)
try:
return payload.decode(charset)
except (LookupError, UnicodeDecodeError):
raise


def parse_json(payload: str) -> Dict[str, Any]:
Expand Down
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)
39 changes: 39 additions & 0 deletions sinch/domains/conversation/models/v1/webhooks/__init__.py
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.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",
]
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,
]
Loading