Skip to main content

Custom Telephony Provider

Build your own telephony provider integration for Menace Voice
5 min read

Prerequisites#

This guide assumes familiarity with two libraries the provider interface is built on:

  • Pipecat — the real-time voice/multimodal pipeline framework Menace Voice's call pipeline is built on. Its FastAPIWebsocketTransport is what streams audio between the telephony provider and Menace Voice's pipeline — you'll be constructing one of these in your transport factory. If you haven't built a Pipecat transport or frame serializer before, skim their transports guide first.
  • Pydantic — used for the request/response schemas that define a provider's stored credentials (config.py below). If you're new to Pydantic, its models documentation covers everything used here (BaseModel, Field, Literal discriminators).

You don't need deep expertise in either — enough to read a BaseModel and a Pipecat transport class is sufficient to follow this guide.

Overview#

A telephony provider is implemented as a self-registering package under api/services/telephony/providers/<name>/. The package contributes everything Menace Voice needs to wire the provider in — the provider class, transport factory, audio config, request/response schemas, optional HTTP routes, and the form metadata used to render its configuration UI — through a single ProviderSpec registered at import time.

Adding a new provider should not require touching the factory, the audio config, the API routes module, the run-pipeline module, or the frontend. The only edits outside the provider folder are:

  1. One import line in api/services/telephony/providers/__init__.py
  2. One import line in api/schemas/telephony_config.py to add the request/response classes to the TelephonyConfigRequest discriminated union

Provider Package Layout#

Three files are required (__init__.py, config.py, provider.py, transport.py). The rest are optional and are discovered automatically when present:

  • routes.py — if the module exists and exports router: APIRouter, the routes module is imported lazily and mounted under /api/v1/telephony by api.routes.telephony via importlib. Providers that only stream over WebSocket (e.g. ARI) can omit it.
  • strategies.py — used by transports that need provider-specific call transfer/hangup logic in the frame serializer (e.g. Twilio Conference transfers).
  • serializers.py — typically a re-export from pipecat. Keep the file even when it's a one-line re-export so transport code imports from .serializers, giving you an obvious place to drop a custom subclass later.

The TelephonyProvider Interface#

Subclass TelephonyProvider in provider.py:

See api/services/telephony/base.py for the full docstrings on each method.

Warning

get_webhook_response and handle_websocket receive an organization_id, not a user id. It is the tenant that every workflow and workflow-run lookup must be scoped by — pass it straight through to run_pipeline_telephony. The workflow owner is deliberately not handed to providers; read it off the workflow row if you need it for attribution.

Implementation Guide#

1. Configuration schemas#

Define Pydantic models for the credential payload. The provider Literal discriminator is what makes the schemas dispatch correctly through the registry's discriminated union.

2. Transport factory#

Build the Pipecat FastAPIWebsocketTransport for accepted WebSockets. Always load credentials through load_credentials_for_transport so the right config row is picked when the workflow run carries a telephony_configuration_id (multi-config orgs).

3. Routes (optional)#

If your provider POSTs webhooks to Menace Voice (answer URL, status callbacks, hangup callbacks), expose them through a module-level router. The routes are auto-mounted under /api/v1/telephony.

Routes are loaded lazily via importlib from api.routes.telephony._mount_provider_routers, so your route module can freely import other backend services without creating import cycles at provider-class load time.

4. Register the ProviderSpec#

The package's __init__.py is where everything comes together:

ProviderSpec covers everything downstream code needs:

FieldUsed by
nameStored as the discriminator on every TelephonyConfiguration row and as the WorkflowRunMode value
provider_clsfactory.get_default_telephony_provider, get_telephony_provider_by_id, get_telephony_provider_for_run
config_loaderfactory._normalize_with_phone_numbers (replaces the old if/elif chain)
transport_factoryrun_pipeline_telephony
audio_configcreate_audio_config() and run_pipeline_telephony
config_request_cls / config_response_clsTelephonyConfigRequest discriminated union
ui_metadataGET /api/v1/organizations/telephony-providers/metadata (drives the form UI) and the _sensitive_fields masking helper
account_id_credential_fieldInbound webhook routing across multiple configs of the same provider

5. Wire the package into the registry import chain#

Add one import line to api/services/telephony/providers/__init__.py:

6. Add to the discriminated union#

Add one import block to api/schemas/telephony_config.py so the request/response classes participate in the TelephonyConfigRequest union and the TelephonyConfigurationResponse shape:

That's it for backend wiring.

Frontend#

The configuration form is metadata-driven. The UI calls GET /api/v1/organizations/telephony-providers/metadata, gets back the list of providers and their ProviderUIField definitions, and renders each form generically. No per-provider frontend code is needed — your ProviderUIMetadata declaration is what drives the form.

If you add a new field type that the existing renderer doesn't support (e.g. a file upload), extend the renderer in ui/src/app/(authenticated)/telephony-configurations/. The supported ProviderUIField.type values today are text, password, textarea, string-array, and number.

Audio Format Considerations#

Each provider declares its wire format through its AudioConfig. Common shapes:

  • Twilio / Plivo: 8 kHz μ-law, base64-encoded JSON frames
  • Vonage: 16 kHz Linear PCM as binary frames
  • Asterisk ARI: 8 kHz Linear PCM via externalMedia

The pipeline sample rate is capped at 16 kHz to satisfy VAD, which only accepts 8000 Hz or 16000 Hz; transports handle resampling between the wire format and the pipeline's internal rate.

Testing#

For end-to-end testing, save your provider through the telephony-configurations UI and trigger a test call from a workflow.

Best Practices#

  1. Trust the registry — never import another provider's class directly; resolve through the factory helpers (get_default_telephony_provider, get_telephony_provider_by_id, etc.).
  2. Sensitive fields — mark every credential field sensitive=True in ProviderUIMetadata. The save endpoint masks these on read and preserves the original when the client re-submits a masked value.
  3. Inbound signature verification — always validate inbound webhook signatures in verify_inbound_signature, and fail closed. If your provider signs every webhook (most do), a missing signature header means the request did not come from the provider: return False, exactly as providers/twilio/ and providers/plivo/ do. Never return True as a placeholder — these handlers are reachable by anyone who can guess a workflow_run_id.
  4. Transports load credentials lazily — call load_credentials_for_transport with the telephony_configuration_id from the workflow run. Don't read the org's default config from transport.py.
  5. Logging — use loguru.logger.

Reference Implementations#

ProviderNotable for
providers/twilio/Full-featured: outbound, inbound, conference transfers, status callbacks, custom strategies
providers/plivo/Recently-added reference; mirrors Twilio's shape with multi-callback signatures
providers/vonage/JWT auth, 16 kHz Linear PCM, NCCO responses
providers/cloudonix/SIP-based, custom call strategies
providers/telnyx/Call-control style: REST-driven inbound answer flow rather than markup response
providers/ari/Minimal example — no routes.py, no inbound webhook verification, WebSocket-only
Note

Use ARI as the smallest viable example when your provider doesn't expose HTTP webhooks, and Twilio as the reference when it does.