diff --git a/examples/.env.example b/examples/snippets/.env.example similarity index 100% rename from examples/.env.example rename to examples/snippets/.env.example diff --git a/examples/README.md b/examples/snippets/README.md similarity index 95% rename from examples/README.md rename to examples/snippets/README.md index 922fb91d..35bb6550 100644 --- a/examples/README.md +++ b/examples/snippets/README.md @@ -2,7 +2,7 @@ Sinch Python SDK Code Snippets -This repository contains code snippets demonstrating usage of the +This directory contains code snippets demonstrating usage of the [Sinch Python SDK](https://github.com/sinch/sinch-sdk-python). ## Requirements diff --git a/examples/pyproject.toml b/examples/snippets/pyproject.toml similarity index 100% rename from examples/pyproject.toml rename to examples/snippets/pyproject.toml diff --git a/examples/webhooks/.env.example b/examples/webhooks/.env.example new file mode 100644 index 00000000..02e98c4b --- /dev/null +++ b/examples/webhooks/.env.example @@ -0,0 +1,9 @@ +# Server Configuration +SERVER_PORT = + +# Webhook Configuration +# The secret value used for webhook calls validation +# 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 \ No newline at end of file diff --git a/examples/webhooks/README.md b/examples/webhooks/README.md new file mode 100644 index 00000000..8d9674c5 --- /dev/null +++ b/examples/webhooks/README.md @@ -0,0 +1,105 @@ +# Webhook 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. + +The webhook handlers are organized by service: +- **SMS**: Handlers for SMS webhook events (`sms_api/`) +- **Numbers**: Handlers for Numbers API webhook events (`numbers_api/`) + +This directory contains both the webhook handlers and the server application (`server.py`) that uses them. + +## Requirements + +- [Python 3.9+](https://www.python.org/) +- [Flask](https://flask.palletsprojects.com/en/stable/) +- [Sinch account](https://dashboard.sinch.com/) +- [ngrok](https://ngrok.com/docs) +- [Poetry](https://python-poetry.org/) + +## Configuration + +1. **Environment Variables**: + Rename [.env.example](.env.example) to `.env` in this directory (`examples/webhooks/`), then add your credentials from the Sinch dashboard under the Access Keys section. + + - Server Port: + Define the port your server will listen to on (default: 3001): + ``` + SERVER_PORT=3001 + ``` + + - Controller Settings + - Numbers controller: Set the `numbers` webhook secret. You can retrieve it using the `/callback_configuration` endpoint (see SDK implementation: [callback_configuration_apis.py](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/numbers/api/v1/callback_configuration_apis.py); for additional details, refer to the [Numbers API callbacks documentation](https://developers.sinch.com/docs/numbers/api-reference/numbers/tag/Numbers-Callbacks/)): + ``` + NUMBERS_WEBHOOKS_SECRET=Your Sinch Numbers Webhook Secret + ``` + - SMS controller: To configure the `sms` webhooks secret, contact your account manager to enable authentication for SMS callbacks. For more details, refer to + [SMS API](https://developers.sinch.com/docs/sms/api-reference/sms/tag/Webhooks/#tag/Webhooks/section/Callbacks), + + ``` + SMS_WEBHOOKS_SECRET=Your Sinch SMS Webhook Secret + ``` + +## Usage + +### Running the server application + +1. Navigate to the webhooks' directory: +``` + cd examples/webhooks +``` + +2. Install the project dependencies: +``` bash + poetry install +``` + +3. Start the server: +``` bash + poetry run python server.py +``` +Or run it directly: +``` bash + python server.py +``` + +The server will start on the port specified in your `.env` file (default: 3001). + +### Endpoints + +The server exposes the following endpoints: + +| Service | Endpoint | +|--------------|--------------------| +| Numbers | /NumbersEvent | +| SMS | /SmsEvent | + +## Using ngrok to expose your local server + +To test your webhook 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))* + +```bash + ngrok http 3001 +``` + +You'll see output similar to this: +``` +ngrok (Ctrl+C to quit) +... +Forwarding https://adbd-79-148-170-158.ngrok-free.app -> http://localhost:3001 +``` +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 + +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. + +You can also set these callback URLs in the Sinch dashboard; the API parameters above override the default values configured there. + +> **Note**: If you have set a webhook secret (e.g., `SMS_WEBHOOKS_SECRET`), the webhook URL must be configured in the Sinch dashboard +> and cannot be overridden via API parameters. The webhook secret is used to validate incoming webhook requests, +> and the URL associated with it must be set in the dashboard. diff --git a/examples/webhooks/numbers_api/__init__.py b/examples/webhooks/numbers_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/webhooks/numbers_api/controller.py b/examples/webhooks/numbers_api/controller.py new file mode 100644 index 00000000..afe5666a --- /dev/null +++ b/examples/webhooks/numbers_api/controller.py @@ -0,0 +1,31 @@ +from flask import request, Response +from webhooks.numbers_api.server_business_logic import handle_numbers_event + + +class NumbersController: + 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 numbers_event(self): + headers = dict(request.headers) + body_str = request.raw_body.decode('utf-8') if request.raw_body else '' + + 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 + ) + + if not valid_auth: + return Response(status=401) + + event = webhooks_service.parse_event(body_str) + + handle_numbers_event(numbers_event=event, logger=self.logger) + + return Response(status=200) diff --git a/examples/webhooks/numbers_api/server_business_logic.py b/examples/webhooks/numbers_api/server_business_logic.py new file mode 100644 index 00000000..80082812 --- /dev/null +++ b/examples/webhooks/numbers_api/server_business_logic.py @@ -0,0 +1,11 @@ +from sinch.domains.numbers.webhooks.v1.events.numbers_webhooks_event import NumbersWebhooksEvent + + +def handle_numbers_event(numbers_event: NumbersWebhooksEvent, logger): + """ + This method handles a Numbers event. + Args: + numbers_event (NumbersWebhooksEvent): The Numbers event data. + logger (logging.Logger, optional): Logger instance for logging. Defaults to None. + """ + logger.info(f'Handling Numbers event:\n{numbers_event.model_dump_json(indent=2)}') diff --git a/examples/webhooks/pyproject.toml b/examples/webhooks/pyproject.toml new file mode 100644 index 00000000..76a53090 --- /dev/null +++ b/examples/webhooks/pyproject.toml @@ -0,0 +1,17 @@ +[tool.poetry] +name = "sinch-sdk-python-quickstart-server" +version = "0.1.0" +description = "Sinch SDK Python Quickstart Webhooks Server" +readme = "README.md" +package-mode = false + +[tool.poetry.dependencies] +python = "^3.9" +python-dotenv = "^1.0.0" +flask = "^3.0.0" +# TODO: Uncomment once v2.0 is released +# sinch = "^2.0.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/examples/webhooks/server.py b/examples/webhooks/server.py new file mode 100644 index 00000000..d7f6f1ca --- /dev/null +++ b/examples/webhooks/server.py @@ -0,0 +1,41 @@ +import logging +import sys +from pathlib import Path + +# Add examples directory to Python path to allow importing webhooks +examples_dir = Path(__file__).resolve().parent.parent +if str(examples_dir) not in sys.path: + sys.path.insert(0, str(examples_dir)) + +from flask import Flask, request +from webhooks.numbers_api.controller import NumbersController +from webhooks.sms_api.controller import SmsController +from webhooks.sinch_client_helper import get_sinch_client, load_config + +app = Flask(__name__) + +config = load_config() +port = int(config.get('SERVER_PORT') or 3001) +numbers_webhooks_secret = config.get('NUMBERS_WEBHOOKS_SECRET') +sms_webhooks_secret = config.get('SMS_WEBHOOKS_SECRET') +sinch_client = get_sinch_client(config) + +# Set up logging at the INFO level +logging.basicConfig() +sinch_client.configuration.logger.setLevel(logging.INFO) + +numbers_controller = NumbersController(sinch_client, numbers_webhooks_secret) +sms_controller = SmsController(sinch_client, sms_webhooks_secret) + + +# Middleware to capture raw body +@app.before_request +def before_request(): + request.raw_body = request.get_data() + + +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) + +if __name__ == '__main__': + app.run(port=port) diff --git a/examples/webhooks/sinch_client_helper.py b/examples/webhooks/sinch_client_helper.py new file mode 100644 index 00000000..109fce1c --- /dev/null +++ b/examples/webhooks/sinch_client_helper.py @@ -0,0 +1,34 @@ +from pathlib import Path +from sinch import SinchClient +from dotenv import dotenv_values + + +def load_config() -> dict[str, str]: + """ + Load configuration from the .env file in the webhooks directory. + + Returns: + dict[str, str]: Dictionary containing configuration values + """ + # Get the directory where this file is located + current_dir = Path(__file__).resolve().parent + env_file = current_dir / '.env' + + if not env_file.exists(): + raise FileNotFoundError(f"Could not find .env file in webhooks directory: {env_file}") + + config_dict = dotenv_values(env_file) + + return config_dict + + +def get_sinch_client(config: dict) -> SinchClient: + """ + Create and return a configured SinchClient instance. + + Args: + config (dict): Dictionary containing configuration values + Returns: + SinchClient: Configured Sinch client instance + """ + return SinchClient() diff --git a/examples/webhooks/sms_api/__init__.py b/examples/webhooks/sms_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/webhooks/sms_api/controller.py b/examples/webhooks/sms_api/controller.py new file mode 100644 index 00000000..dbebd1de --- /dev/null +++ b/examples/webhooks/sms_api/controller.py @@ -0,0 +1,37 @@ +from flask import request, Response +from webhooks.sms_api.server_business_logic import ( + handle_sms_event, +) + + +class SmsController: + 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 sms_event(self): + headers = dict(request.headers) + + body_str = request.raw_body.decode('utf-8') if request.raw_body else '' + + webhooks_service = self.sinch_client.sms.webhooks(self.webhooks_secret) + + # Signature headers may be absent unless your account manager enables them + # (see README: Configuration -> Controller Settings -> SMS controller); + # leave auth disabled here unless SMS callbacks are configured. + ensure_valid_authentication = False + if ensure_valid_authentication: + valid_auth = webhooks_service.validate_authentication_header( + headers=headers, + json_payload=body_str + ) + + if not valid_auth: + return Response(status=401) + + event = webhooks_service.parse_event(body_str) + + handle_sms_event(sms_event=event, logger=self.logger) + + return Response(status=200) diff --git a/examples/webhooks/sms_api/server_business_logic.py b/examples/webhooks/sms_api/server_business_logic.py new file mode 100644 index 00000000..aa47e470 --- /dev/null +++ b/examples/webhooks/sms_api/server_business_logic.py @@ -0,0 +1,11 @@ +from sinch.domains.sms.webhooks.v1.events.sms_webhooks_event import IncomingSMSWebhookEvent + + +def handle_sms_event(sms_event: IncomingSMSWebhookEvent, logger): + """ + This method handles an SMS event. + Args: + sms_event (SmsWebhooksEvent): The SMS event data. + logger (logging.Logger, optional): Logger instance for logging. Defaults to None. + """ + logger.info(f'Handling SMS event:\n{sms_event.model_dump_json(indent=2)}') diff --git a/sinch/core/clients/sinch_client_configuration.py b/sinch/core/clients/sinch_client_configuration.py index 0fe559ec..1db8e7d9 100644 --- a/sinch/core/clients/sinch_client_configuration.py +++ b/sinch/core/clients/sinch_client_configuration.py @@ -266,8 +266,21 @@ def get_sms_origin_for_auth(self): Returns the appropriate SMS origin based on the authentication method. - SMS auth (service_plan_id + sms_api_token): uses sms_origin_with_service_plan_id - Project auth (project_id): uses regular sms_origin + + Raises: + ValueError: If the SMS origin is None (sms_region not set) """ if self._authentication_method == "sms_auth": - return self.sms_origin_with_service_plan_id + origin = self.sms_origin_with_service_plan_id else: - return self.sms_origin + origin = self.sms_origin + + if origin is None: + raise ValueError( + "SMS region is required. " + "Provide sms_region when initializing SinchClient " + "Example: SinchClient(project_id='...', key_id='...', key_secret='...', sms_region='eu')" + "or set it via sinch_client.configuration.sms_region. " + ) + + return origin diff --git a/sinch/domains/sms/models/batches/__init__.py b/sinch/domains/sms/models/batches/__init__.py deleted file mode 100644 index 1e352ded..00000000 --- a/sinch/domains/sms/models/batches/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class Batch(SinchRequestBaseModel): - id: str - to: list - from_: str - body: str - delivery_report: str - cancelled: str - type: str - campaign_id: str - created_at: str - modified_at: str - send_at: str - expire_at: str - callback_url: str = None - client_reference: str = None - feedback_enabled: bool = None - flash_message: bool = None diff --git a/sinch/domains/sms/models/batches/requests.py b/sinch/domains/sms/models/batches/requests.py deleted file mode 100644 index 99b6cb9d..00000000 --- a/sinch/domains/sms/models/batches/requests.py +++ /dev/null @@ -1,108 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class BatchRequest(SinchRequestBaseModel): - def as_dict(self): - payload = super(BatchRequest, self).as_dict() - payload["to"] = payload.pop("to") - if payload.get("from_"): - payload["from"] = payload.pop("from_") - return payload - - -@dataclass -class SendBatchRequest(BatchRequest): - to: list - from_: str - body: str - delivery_report: str - parameters: dict - send_at: str - expire_at: str - callback_url: str - client_reference: str - feedback_enabled: bool - flash_message: bool - truncate_concat: bool - type_: str - max_number_of_message_parts: int - from_ton: int - from_npi: int - - -@dataclass -class ListBatchesRequest(SinchRequestBaseModel): - page_size: int - from_s: str - start_date: str - end_date: str - client_reference: str - page: int = 0 - - -@dataclass -class GetBatchRequest(SinchRequestBaseModel): - batch_id: str - - -@dataclass -class CancelBatchRequest(SinchRequestBaseModel): - batch_id: str - - -@dataclass -class BatchDryRunRequest(BatchRequest): - per_recipient: bool - number_of_recipients: int - to: str - from_: str - body: str - type_: str - udh: str - delivery_report: str - parameters: dict - send_at: str - expire_at: str - callback_url: str - client_reference: str - flash_message: bool - max_number_of_message_parts: int - - -@dataclass -class UpdateBatchRequest(SinchRequestBaseModel): - batch_id: str - to_add: list - to_remove: list - from_: str - body: str - delivery_report: str - send_at: str - expire_at: str - callback_url: str - - -@dataclass -class ReplaceBatchRequest(BatchRequest): - batch_id: str - to: str - from_: str - body: str - delivery_report: str - parameters: dict - send_at: str - expire_at: str - type_: str - callback_url: str - client_reference: str - flash_message: bool - max_number_of_message_parts: int - udh: str - - -@dataclass -class SendDeliveryFeedbackRequest(SinchRequestBaseModel): - batch_id: str - recipients: list diff --git a/sinch/domains/sms/models/batches/responses.py b/sinch/domains/sms/models/batches/responses.py deleted file mode 100644 index a0e918f5..00000000 --- a/sinch/domains/sms/models/batches/responses.py +++ /dev/null @@ -1,60 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.sms.models.batches import Batch - - -@dataclass -class SendSMSBatchResponse(Batch): - pass - - -@dataclass -class ReplaceSMSBatchResponse(Batch): - pass - - -@dataclass -class ListSMSBatchesResponse(SinchBaseModel): - page: str - page_size: str - count: str - batches: list - - -@dataclass -class GetSMSBatchResponse(Batch): - pass - - -@dataclass -class CancelSMSBatchResponse(Batch): - pass - - -@dataclass -class SendSMSBatchDryRunResponse(SinchBaseModel): - number_of_recipients: int - number_of_messages: int - per_recipient: list - - -@dataclass -class UpdateSMSBatchResponse(SinchBaseModel): - id: str - to: list - from_: str - body: str - campaign_id: str - delivery_report: str - send_at: str - expire_at: str - callback_url: str - cancelled: bool - type: str - created_at: str - modified_at: str - - -@dataclass -class SendSMSDeliveryFeedbackResponse(SinchBaseModel): - pass diff --git a/sinch/domains/sms/models/delivery_reports/__init__.py b/sinch/domains/sms/models/delivery_reports/__init__.py deleted file mode 100644 index b6c40915..00000000 --- a/sinch/domains/sms/models/delivery_reports/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class DeliveryReport(SinchBaseModel): - at: str - batch_id: str - code: int - recipient: str - status: str - applied_originator: str - client_reference: str - encoding: str - number_of_message_parts: int - operator: str - operator_status_at: str - type: str diff --git a/sinch/domains/sms/models/delivery_reports/requests.py b/sinch/domains/sms/models/delivery_reports/requests.py deleted file mode 100644 index 09857fd3..00000000 --- a/sinch/domains/sms/models/delivery_reports/requests.py +++ /dev/null @@ -1,27 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class ListSMSDeliveryReportsRequest(SinchRequestBaseModel): - code: str - status: str - start_date: str - end_date: str - client_reference: str - page_size: int - page: int = 0 - - -@dataclass -class GetSMSDeliveryReportForBatchRequest(SinchRequestBaseModel): - batch_id: str - type_: str - status: list - code: list - - -@dataclass -class GetSMSDeliveryReportForNumberRequest(SinchRequestBaseModel): - batch_id: str - recipient_number: str diff --git a/sinch/domains/sms/models/delivery_reports/responses.py b/sinch/domains/sms/models/delivery_reports/responses.py deleted file mode 100644 index 18a53ba4..00000000 --- a/sinch/domains/sms/models/delivery_reports/responses.py +++ /dev/null @@ -1,34 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class ListSMSDeliveryReportsResponse(SinchBaseModel): - page: str - page_size: str - count: str - delivery_reports: list - - -@dataclass -class GetSMSDeliveryReportForBatchResponse(SinchBaseModel): - type: str - batch_id: str - total_message_count: str - statuses: list - client_reference: str - - -@dataclass -class GetSMSDeliveryReportForNumberResponse(SinchBaseModel): - at: str - batch_id: str - code: int - recipient: str - status: str - applied_originator: str - client_reference: str - number_of_message_parts: str - operator: str - operator_status_at: str - type: str diff --git a/sinch/domains/sms/sms.py b/sinch/domains/sms/sms.py index f53bab95..3c5c3ff3 100644 --- a/sinch/domains/sms/sms.py +++ b/sinch/domains/sms/sms.py @@ -2,6 +2,7 @@ Batches, DeliveryReports, ) +from sinch.domains.sms.webhooks.v1.sms_webhooks import SmsWebhooks class SMS: @@ -15,3 +16,14 @@ def __init__(self, sinch): self.batches = Batches(self._sinch) self.delivery_reports = DeliveryReports(self._sinch) + + def webhooks(self, callback_secret: str) -> SmsWebhooks: + """ + Create an SMS webhooks handler with the specified callback secret. + + :param callback_secret: Secret used for webhook validation. + :type callback_secret: str + :returns: A configured webhooks handler + :rtype: SmsWebhooks + """ + return SmsWebhooks(callback_secret)