SMS Providers¶
Webhook Integration (Recommended)¶
The simplest way to handle SMS webhooks from any provider is using extract_sms_meta() combined with kit.process_webhook():
from roomkit import RoomKit, extract_sms_meta, parse_voicemeup_webhook
kit = RoomKit()
@app.post("/webhooks/sms/{provider}/inbound")
async def sms_webhook(provider: str, payload: dict):
channel_id = f"sms-{provider}"
# VoiceMeUp needs special handling for MMS aggregation
if provider == "voicemeup":
message = parse_voicemeup_webhook(payload, channel_id=channel_id)
if message is None:
return {"ok": True, "buffered": True} # Waiting for image part
await kit.process_inbound(message)
return {"ok": True}
# All other providers: generic path
meta = extract_sms_meta(provider, payload)
if meta.is_inbound:
await kit.process_inbound(meta.to_inbound(channel_id))
elif meta.is_status:
await kit.process_delivery_status(meta.to_status())
return {"ok": True}
Delivery Status Tracking¶
Providers send webhooks for delivery status updates (sent, delivered, failed). Register a handler to track these:
from roomkit import DeliveryStatus
@kit.on_delivery_status
async def track_delivery(status: DeliveryStatus):
if status.status == "delivered":
logger.info("Message %s delivered to %s", status.message_id, status.recipient)
elif status.status == "failed":
logger.error("Message %s failed: %s", status.message_id, status.error_message)
Base Classes¶
SMSProvider ¶
Bases: ABC
SMS delivery provider.
send
abstractmethod
async
¶
Send an SMS message.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
event
|
RoomEvent
|
The room event containing the message content. |
required |
to
|
str
|
Recipient phone number (E.164 format). |
required |
from_
|
str | None
|
Sender phone number override. Defaults to |
None
|
Returns:
| Type | Description |
|---|---|
ProviderResult
|
Result with provider-specific delivery metadata. |
parse_webhook
async
¶
Parse an inbound webhook payload into an InboundMessage.
verify_signature ¶
Verify that a webhook payload was signed by the provider.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
payload
|
bytes
|
Raw request body bytes. |
required |
signature
|
str
|
Signature header value from the webhook request. |
required |
timestamp
|
str | None
|
Timestamp header value (required by some providers). |
None
|
Returns:
| Type | Description |
|---|---|
bool
|
True if the signature is valid, False otherwise. |
Raises:
| Type | Description |
|---|---|
NotImplementedError
|
If the provider does not support signature verification. |
MockSMSProvider ¶
MMS Handling¶
RoomKit supports MMS (Multimedia Messaging Service) through SMS providers. When an MMS arrives, the channel type is automatically set to mms instead of sms.
Media URL Re-hosting¶
Important: Provider media URLs (from VoiceMeUp, Twilio, etc.) should be re-hosted before forwarding to other channels. This is necessary because:
- Provider URLs may be temporary and expire
- Some providers block certain services (e.g., AI providers cannot fetch VoiceMeUp URLs)
- You maintain control over media availability
Recommended: Use a BEFORE_BROADCAST hook to automatically re-host media URLs before they reach AI channels. See VoiceMeUp Media URL Restrictions for a complete example.
Provider Comparison¶
| Provider | MMS Support | Webhook Format | Notes |
|---|---|---|---|
| Twilio | Single webhook | MediaUrl0, MediaUrl1... |
Up to 10 media per message |
| Sinch | Single webhook | media[] array |
Full MMS support |
| Telnyx | Single webhook | media_urls[] array |
Full MMS support |
| VoiceMeUp | Split webhooks | See below | Requires aggregation |
Sinch¶
SinchSMSProvider ¶
Bases: SMSProvider
SMS provider using the Sinch REST API.
verify_signature ¶
Verify a Sinch webhook signature using HMAC-SHA1.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
payload
|
bytes
|
Raw request body bytes (JSON). |
required |
signature
|
str
|
Value of the |
required |
timestamp
|
str | None
|
Not used by Sinch (included for interface compatibility). |
None
|
Returns:
| Type | Description |
|---|---|
bool
|
True if the signature is valid, False otherwise. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If webhook_secret was not provided in config. |
SinchConfig ¶
Bases: BaseModel
Sinch SMS provider configuration.
parse_sinch_webhook ¶
Convert a Sinch SMS webhook POST body into an InboundMessage.
Sinch inbound SMS webhook structure: { "id": "message-id", "from": "+15551234567", "to": "12345", "body": "Message text", "received_at": "2026-01-28T12:00:00.000Z", "operator_id": "...", "media": [{"url": "...", "mimeType": "image/jpeg"}], ... }
Telnyx¶
TelnyxSMSProvider ¶
Bases: SMSProvider
SMS provider using the Telnyx REST API.
Initialize the Telnyx SMS provider.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config
|
TelnyxConfig
|
Telnyx configuration. |
required |
public_key
|
str | None
|
Telnyx public key for webhook signature verification. Found in Mission Control Portal > Keys & Credentials > Public Key. |
None
|
verify_signature ¶
Verify a Telnyx webhook signature using ED25519.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
payload
|
bytes
|
Raw request body bytes. |
required |
signature
|
str
|
Value of the |
required |
timestamp
|
str | None
|
Value of the |
None
|
Returns:
| Type | Description |
|---|---|
bool
|
True if the signature is valid, False otherwise. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If public_key was not provided to the constructor. |
ImportError
|
If PyNaCl is not installed. |
TelnyxConfig ¶
Bases: BaseModel
Telnyx SMS provider configuration.
Twilio¶
TwilioSMSProvider ¶
Bases: SMSProvider
SMS provider using the Twilio REST API.
verify_signature ¶
Verify a Twilio webhook signature using HMAC-SHA1.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
payload
|
bytes
|
Raw request body bytes (form-encoded). |
required |
signature
|
str
|
Value of the |
required |
timestamp
|
str | None
|
Not used by Twilio (included for interface compatibility). |
None
|
url
|
str | None
|
The full URL that Twilio called (required for verification). |
None
|
Returns:
| Type | Description |
|---|---|
bool
|
True if the signature is valid, False otherwise. |
TwilioConfig ¶
Bases: BaseModel
Twilio SMS provider configuration.
parse_twilio_webhook ¶
Convert a Twilio webhook POST body into an InboundMessage.
Twilio sends webhooks as form-encoded data. Convert to dict first:
payload = dict(await request.form())
VoiceMeUp¶
VoiceMeUpSMSProvider ¶
VoiceMeUpConfig ¶
Bases: BaseModel
VoiceMeUp SMS provider configuration.
parse_voicemeup_webhook ¶
Parse a VoiceMeUp webhook and return an InboundMessage.
Automatically handles MMS aggregation: VoiceMeUp sends MMS as two separate webhooks (text + metadata first, image second). This function buffers the first part and merges it with the second.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
payload
|
dict[str, Any]
|
The webhook POST body from VoiceMeUp |
required |
channel_id
|
str
|
The channel ID to associate with this message |
required |
Returns:
| Type | Description |
|---|---|
InboundMessage | None
|
InboundMessage if ready to process (SMS or merged MMS) |
InboundMessage | None
|
None if buffered (waiting for second MMS part) |
Example
@app.post("/webhooks/sms/voicemeup") async def webhook(payload: dict): message = parse_voicemeup_webhook(payload, channel_id="sms") if message: await kit.process_inbound(message) return {"ok": True}
VoiceMeUp MMS Split Webhooks¶
VoiceMeUp handles MMS differently from other providers. When a user sends an MMS (text + image), VoiceMeUp sends two separate webhooks:
- First webhook: Contains the text message + a
.mms.htmlmetadata wrapper (not the actual image) - Second webhook: Contains the actual image (no text)
Both webhooks have different sms_hash values, making correlation non-trivial.
Without aggregation, you get two separate events — confusing for AI channels that receive the text question in one event and the image in another.
Automatic MMS Aggregation¶
parse_voicemeup_webhook() automatically handles MMS aggregation. When the first webhook arrives (text + .mms.html), it buffers the message and returns None. When the second webhook arrives (image), it merges them and returns a complete InboundMessage.
Usage example:
from roomkit import parse_voicemeup_webhook, configure_voicemeup_mms
# Optional: configure timeout behavior (default: 5s)
configure_voicemeup_mms(
timeout_seconds=5.0,
on_timeout=handle_orphaned_text, # Called if image never arrives
)
@app.post("/webhooks/sms/voicemeup/inbound")
async def voicemeup_webhook(payload: dict):
message = parse_voicemeup_webhook(payload, channel_id="sms")
if message is None:
# First part buffered, waiting for image
return {"ok": True, "buffered": True}
# Complete message (SMS or merged MMS)
await kit.process_inbound(message)
return {"ok": True}
configure_voicemeup_mms ¶
Configure VoiceMeUp MMS aggregation behavior.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
timeout_seconds
|
float
|
How long to wait for the second MMS part (default: 5.0) |
5.0
|
on_timeout
|
Callable[[InboundMessage], Awaitable[None] | None] | None
|
Callback invoked with text-only message if image never arrives. If not set, orphaned text messages are logged and discarded. |
None
|
Example
async def handle_orphaned_mms(message: InboundMessage) -> None: await kit.process_inbound(message)
configure_voicemeup_mms(timeout_seconds=5.0, on_timeout=handle_orphaned_mms)
How it works:
| Webhook | Content | Action |
|---|---|---|
First (.mms.html) |
Text + metadata | Buffer, return None |
| Second (image) | Media only | Merge with buffered text, return InboundMessage |
| Timeout | - | Emit text-only via on_timeout callback |
Correlation key: source_number:destination_number:datetime_transmission
VoiceMeUp Media URL Restrictions¶
VoiceMeUp's CDN (clients.voicemeup.com) blocks certain services via robots.txt, including Anthropic's image fetcher. Always re-host VoiceMeUp media before sending to AI channels.
Recommended: Use a filtered BEFORE_BROADCAST hook to automatically re-host media URLs only for inbound MMS:
import httpx
from urllib.parse import urlparse
from roomkit import (
RoomKit, HookTrigger, HookResult, RoomEvent, RoomContext,
ChannelType, ChannelDirection, MediaContent, CompositeContent,
)
# Domains that need re-hosting
REHOST_DOMAINS = {"clients.voicemeup.com", "dev-clients.voicemeup.com"}
def needs_rehosting(url: str) -> bool:
return urlparse(url).netloc in REHOST_DOMAINS
async def rehost_url(url: str) -> str:
"""Download media and upload to your storage."""
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url)
response.raise_for_status()
# Upload to S3, Cloudflare R2, or local storage
new_url = await upload_to_storage(response.content)
return new_url
async def rehost_media_hook(event: RoomEvent, ctx: RoomContext) -> HookResult:
"""Re-host provider media URLs before broadcast to AI channels."""
content = event.content
if isinstance(content, MediaContent) and needs_rehosting(content.url):
event.content = MediaContent(
url=await rehost_url(content.url),
mime_type=content.mime_type,
caption=content.caption,
)
elif isinstance(content, CompositeContent):
new_parts = []
for part in content.parts:
if isinstance(part, MediaContent) and needs_rehosting(part.url):
new_parts.append(MediaContent(
url=await rehost_url(part.url),
mime_type=part.mime_type,
caption=part.caption,
))
else:
new_parts.append(part)
event.content = CompositeContent(parts=new_parts)
return HookResult.allow()
# Register the hook with filters — only runs for inbound SMS/MMS
kit = RoomKit()
kit.hook(
HookTrigger.BEFORE_BROADCAST,
name="rehost_media",
channel_types={ChannelType.SMS, ChannelType.MMS},
directions={ChannelDirection.INBOUND},
)(rehost_media_hook)
Hook filtering ensures the hook only runs for relevant events:
- channel_types — Only SMS and MMS events (not email, websocket, etc.)
- directions — Only inbound events (not AI responses going outbound)
- channel_ids — Optionally restrict to specific channel IDs (e.g., {"sms-voicemeup"})
Utilities¶
Webhook Metadata¶
WebhookMeta
dataclass
¶
WebhookMeta(provider, sender, recipient, body, external_id, timestamp, raw, media_urls=list(), direction=None, event_type=None)
Normalized metadata extracted from any SMS provider webhook.
Attributes:
| Name | Type | Description |
|---|---|---|
provider |
str
|
Provider name (e.g., "telnyx", "twilio"). |
sender |
str
|
Phone number that sent the message. |
recipient |
str
|
Phone number that received the message. |
body |
str
|
Message text content. |
external_id |
str | None
|
Provider's unique message identifier. |
timestamp |
datetime | None
|
When the message was received (if available). |
raw |
dict[str, Any]
|
Original webhook payload for debugging. |
media_urls |
list[dict[str, str | None]]
|
List of media attachments with url and mime_type. |
direction |
str | None
|
Message direction ("inbound" or "outbound"). |
event_type |
str | None
|
Webhook event type (e.g., "message.received"). |
is_inbound
property
¶
Check if this webhook represents an inbound message.
Returns True if direction is "inbound" or event_type indicates a received message. Returns True by default if direction/event_type are not available (backwards compatibility).
to_inbound ¶
Convert to InboundMessage for use with RoomKit.process_inbound().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
channel_id
|
str
|
The channel ID to associate with the message. |
required |
Returns:
| Type | Description |
|---|---|
InboundMessage
|
An InboundMessage ready for process_inbound(). |
Raises:
| Type | Description |
|---|---|
ValueError
|
If this is not an inbound message (e.g., outbound status webhook). |
Example
meta = extract_sms_meta("twilio", payload) sender = normalize_phone(meta.sender) inbound = meta.to_inbound(channel_id="sms-channel") result = await kit.process_inbound(inbound)
extract_sms_meta ¶
Extract normalized metadata from any supported SMS provider webhook.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
provider
|
str
|
Provider name (e.g., "voicemeup", "telnyx"). |
required |
payload
|
dict[str, Any]
|
Raw webhook payload dictionary. |
required |
Returns:
| Type | Description |
|---|---|
WebhookMeta
|
Normalized WebhookMeta with provider-agnostic fields. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the provider is not supported. |
Delivery Status¶
DeliveryStatus ¶
Bases: BaseModel
Status update for an outbound message from a provider webhook.
Providers send status webhooks when messages are sent, delivered, failed, etc. Use this with the ON_DELIVERY_STATUS hook to track outbound message delivery.
Attributes:
| Name | Type | Description |
|---|---|---|
provider |
str
|
Provider name (e.g., "telnyx", "twilio"). |
message_id |
str
|
Provider's unique message identifier. |
status |
str
|
Status string (e.g., "sent", "delivered", "failed"). |
recipient |
str
|
Phone number/address the message was sent to. |
sender |
str
|
Phone number/address the message was sent from. |
error_code |
str | None
|
Provider-specific error code (if failed). |
error_message |
str | None
|
Human-readable error message (if failed). |
timestamp |
str | None
|
When the status was reported. |
raw |
dict[str, Any]
|
Original webhook payload for debugging. |
Phone Normalization¶
normalize_phone ¶
Normalize a phone number to E.164 format.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
number
|
str
|
Phone number in any common format. |
required |
default_region
|
str
|
ISO 3166-1 alpha-2 country code for numbers without country code (default: "US"). |
'US'
|
Returns:
| Type | Description |
|---|---|
str
|
Phone number in E.164 format (e.g., "+14185551234"). |
Raises:
| Type | Description |
|---|---|
ImportError
|
If phonenumbers library is not installed. |
ValueError
|
If the number cannot be parsed or is invalid. |
Example
normalize_phone("418-555-1234", "CA") '+14185551234' normalize_phone("+1 (418) 555-1234") '+14185551234' normalize_phone("14185551234") '+14185551234'
is_valid_phone ¶
Check if a phone number is valid.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
number
|
str
|
Phone number in any common format. |
required |
default_region
|
str
|
ISO 3166-1 alpha-2 country code for numbers without country code (default: "US"). |
'US'
|
Returns:
| Type | Description |
|---|---|
bool
|
True if the number is valid, False otherwise. |
Note
Returns False if phonenumbers library is not installed.