Skip to content

SMS Providers

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.

name property

name

Provider name (e.g. 'twilio', 'sinch').

from_number abstractmethod property

from_number

Default sender phone number.

send abstractmethod async

send(event, to, from_=None)

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

None

Returns:

Type Description
ProviderResult

Result with provider-specific delivery metadata.

parse_webhook async

parse_webhook(payload)

Parse an inbound webhook payload into an InboundMessage.

verify_signature

verify_signature(payload, signature, timestamp=None)

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.

close async

close()

Release resources. Override in subclasses that hold connections.

MockSMSProvider

MockSMSProvider()

Bases: SMSProvider

Records sent messages for verification in tests.

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

SinchSMSProvider(config)

Bases: SMSProvider

SMS provider using the Sinch REST API.

verify_signature

verify_signature(payload, signature, timestamp=None)

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 X-Sinch-Signature header.

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

parse_sinch_webhook(payload, channel_id)

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

TelnyxSMSProvider(config, public_key=None)

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_signature(payload, signature, timestamp=None)

Verify a Telnyx webhook signature using ED25519.

Parameters:

Name Type Description Default
payload bytes

Raw request body bytes.

required
signature str

Value of the Telnyx-Signature-Ed25519 header.

required
timestamp str | None

Value of the Telnyx-Timestamp header.

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

TwilioSMSProvider(config)

Bases: SMSProvider

SMS provider using the Twilio REST API.

verify_signature

verify_signature(payload, signature, timestamp=None, url=None)

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 X-Twilio-Signature header.

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

parse_twilio_webhook(payload, channel_id)

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

VoiceMeUpSMSProvider(config)

Bases: SMSProvider

SMS provider using the VoiceMeUp REST API.

VoiceMeUpConfig

Bases: BaseModel

VoiceMeUp SMS provider configuration.

parse_voicemeup_webhook

parse_voicemeup_webhook(payload, channel_id)

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:

  1. First webhook: Contains the text message + a .mms.html metadata wrapper (not the actual image)
  2. 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(*, timeout_seconds=5.0, on_timeout=None)

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

is_inbound

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

is_status property

is_status

Check if this webhook represents a delivery status update.

to_status

to_status()

Convert to DeliveryStatus for delivery tracking.

to_inbound

to_inbound(channel_id)

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_sms_meta(provider, payload)

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_phone(number, default_region='US')

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

is_valid_phone(number, default_region='US')

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.