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
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Sinch credentials (from dashboard.sinch.com → Access Keys)
SINCH_PROJECT_ID=
SINCH_KEY_ID=
SINCH_KEY_SECRET=

# Conversation API: existing app (already created and configured for SMS).
# SINCH_CONVERSATION_REGION is required.
# Set it to the same region as the one your app was created in (e.g. eu).
CONVERSATION_APP_ID=
SINCH_CONVERSATION_REGION=

# Webhook secret (set when configuring the callback in Sinch dashboard)
CONVERSATION_WEBHOOKS_SECRET=

# Server
SERVER_PORT=3001
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Getting Started: Receive Mobile-originated (MO) SMS and send Mobile-terminated (MT) reply (Conversation API)


This directory contains a small server built with the [Sinch Python SDK](https://github.com/sinch/sinch-sdk-python)
that receives mobile-originated (MO) SMS on your Sinch number and sends a mobile-terminated (MT) SMS back
to the same phone. The reply echoes the incoming text (e.g. *"Your message said: <content of MO>"*) so you can
see that the MO was received and processed.



## Requirements

- [Python 3.9+](https://www.python.org/)
- [Flask](https://flask.palletsprojects.com/en/stable/)
- [Sinch account](https://dashboard.sinch.com/)
- An existing Conversation API app configured for SMS (with a Sinch number)
- [ngrok](https://ngrok.com/docs) (or similar) to expose your local server
- [Poetry](https://python-poetry.org/)

## Configuration

1. **Environment variables**
Copy [.env.example](.env.example) to `.env` in this directory, then set your credentials and app settings.

- Sinch credentials (from the Sinch dashboard, Access Keys):
```
SINCH_PROJECT_ID=your_project_id
SINCH_KEY_ID=your_key_id
SINCH_KEY_SECRET=your_key_secret
```

- Conversation API app (existing app, already configured for SMS). Set `SINCH_CONVERSATION_REGION` to the same region as the one your app was created in (e.g. `eu`):
```
CONVERSATION_APP_ID=your_conversation_app_id
SINCH_CONVERSATION_REGION=
```

- Webhook secret (the value you set when configuring the callback URL for this app).
See [Conversation API callbacks](https://developers.sinch.com/docs/conversation/callbacks):
```
CONVERSATION_WEBHOOKS_SECRET=your_webhook_secret
```

- Server port (optional; default 3001):
```
SERVER_PORT=3001
```

2. **Install dependencies**
From this directory:
```bash
poetry install
```
Install the Sinch SDK from the **repository root**: `pip install -e .` (recommended when developing from this repo).
Alternatively, install with pip: `flask`, `python-dotenv`, and `sinch` (e.g. from PyPI).

## Usage

### Running the server

1. Navigate to this directory:
```
cd examples/getting-started/conversation/send_handle_incoming_sms
```


2. Start the server:
```bash
poetry run python server.py
```
Or run it directly:
```bash
python server.py
```

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


```bash
ngrok http 3001
```

You will see output similar to:
```
Forwarding https://abc123.ngrok-free.app -> http://localhost:3001
```

Use the **HTTPS** URL when configuring the callback:
`https://<your-ngrok-host>/ConversationEvent`

Configure this callback URL (and the webhook secret) in the Sinch dashboard for your Conversation API app.
The webhook secret must match `CONVERSATION_WEBHOOKS_SECRET` in your `.env`.

### Sending an SMS to your Sinch number

Send an SMS from your phone to the **Sinch number** linked to your Conversation API app. You should receive the echo reply on your phone.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from flask import request, Response
from server_business_logic import handle_conversation_event


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

def conversation_event(self):
headers = dict(request.headers)
raw_body = getattr(request, "raw_body", None) or 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,
sinch_client=self.sinch_client,
app_id=self.app_id,
)

return Response(status=200)
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[tool.poetry]
name = "sinch-getting-started-send-handle-incoming-sms"
version = "0.1.0"
description = "Getting Started: send and handle incoming SMS with Conversation API (DISPATCH, channel identity)"
readme = "README.md"
package-mode = false

[tool.poetry.dependencies]
python = "^3.9"
python-dotenv = "^1.0.0"
flask = "^3.0.0"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import logging
from pathlib import Path


from flask import Flask, request
from dotenv import dotenv_values

from sinch import SinchClient
from controller import ConversationController

app = Flask(__name__)


def load_config():
current_dir = Path(__file__).resolve().parent
env_file = current_dir / ".env"
if not env_file.exists():
raise FileNotFoundError(f"Missing .env in {current_dir}. Copy from .env.example.")
return dict(dotenv_values(env_file))


config = load_config()
port = int(config.get("SERVER_PORT") or "3001")
app_id = config.get("CONVERSATION_APP_ID") or ""
webhooks_secret = config.get("CONVERSATION_WEBHOOKS_SECRET") or ""
conversation_region = (config.get("SINCH_CONVERSATION_REGION") or "").strip()
if not conversation_region:
raise ValueError(
"SINCH_CONVERSATION_REGION is required in .env to provide all parameters needed for Conversation API requests. "
"Set it to the same region as the one your Conversation API app was created in (e.g. eu)."
)

sinch_client = SinchClient(
project_id=config.get("SINCH_PROJECT_ID", ""),
key_id=config.get("SINCH_KEY_ID", ""),
key_secret=config.get("SINCH_KEY_SECRET", ""),
conversation_region=conversation_region,
)
logging.basicConfig()
sinch_client.configuration.logger.setLevel(logging.INFO)

conversation_controller = ConversationController(
sinch_client, webhooks_secret, app_id
)


@app.before_request
def before_request():
request.raw_body = request.get_data()


app.add_url_rule(
"/ConversationEvent",
methods=["POST"],
view_func=conversation_controller.conversation_event,
)

if __name__ == "__main__":
print("Getting Started: MO SMS → MT reply (Conversation API, DISPATCH, channel identity)")
print(f"App ID: {app_id or '(set CONVERSATION_APP_ID in .env)'}")
print(f"Listening on port {port}. Expose with: ngrok http {port}")
app.run(port=port)
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""
On inbound SMS (MO), send a reply (MT) to the same number: "Your message said: <text>".
Uses channel identity (SMS + phone number) only; app is in DISPATCH mode.
"""

from sinch.domains.conversation.models.v1.webhooks import MessageInboundEvent


def handle_conversation_event(event, logger, sinch_client, app_id):
"""Webhook entry: handle only MESSAGE_INBOUND; delegate to inbound handler."""
if not isinstance(event, MessageInboundEvent):
return
_handle_message_inbound(event, logger, sinch_client, app_id)


def _get_mo_text(event: MessageInboundEvent) -> str:
"""Return the inbound message text, or a short placeholder if none."""
msg = event.message
contact_msg = msg.contact_message
if getattr(contact_msg, "text_message", None):
return contact_msg.text_message.text or "(empty)"
return "(no text content)"


def _handle_message_inbound(event: MessageInboundEvent, logger, sinch_client, app_id):
"""Parse MO, then send MT echo to the same number via Conversation API."""
msg = event.message
channel_identity = msg.channel_identity
if not channel_identity:
logger.warning("MESSAGE_INBOUND with no channel_identity")
return

identity = channel_identity.identity
mo_text = _get_mo_text(event)
logger.info("MO SMS from %s: %s", identity, mo_text)

if not app_id:
logger.warning("CONVERSATION_APP_ID not set; skipping MT reply.")
return

reply_text = f"Your message said: {mo_text}"
response = sinch_client.conversation.messages.send_text_message(
app_id=app_id,
text=reply_text,
recipient_identities=[{"channel": "SMS", "identity": identity}],
)
logger.info("MT reply sent to %s (channel identity): %s", identity, reply_text[:60])
logger.debug(
"Response: message_id=%s accepted_time=%s",
response.message_id,
response.accepted_time
)