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
FastAPIWebsocketTransportis 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.pybelow). If you're new to Pydantic, its models documentation covers everything used here (BaseModel,Field,Literaldiscriminators).
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:
- One import line in
api/services/telephony/providers/__init__.py - One import line in
api/schemas/telephony_config.pyto add the request/response classes to theTelephonyConfigRequestdiscriminated 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 exportsrouter: APIRouter, the routes module is imported lazily and mounted under/api/v1/telephonybyapi.routes.telephonyviaimportlib. 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.
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:
| Field | Used by |
|---|---|
name | Stored as the discriminator on every TelephonyConfiguration row and as the WorkflowRunMode value |
provider_cls | factory.get_default_telephony_provider, get_telephony_provider_by_id, get_telephony_provider_for_run |
config_loader | factory._normalize_with_phone_numbers (replaces the old if/elif chain) |
transport_factory | run_pipeline_telephony |
audio_config | create_audio_config() and run_pipeline_telephony |
config_request_cls / config_response_cls | TelephonyConfigRequest discriminated union |
ui_metadata | GET /api/v1/organizations/telephony-providers/metadata (drives the form UI) and the _sensitive_fields masking helper |
account_id_credential_field | Inbound 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#
- 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.). - Sensitive fields — mark every credential field
sensitive=TrueinProviderUIMetadata. The save endpoint masks these on read and preserves the original when the client re-submits a masked value. - 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: returnFalse, exactly asproviders/twilio/andproviders/plivo/do. Neverreturn Trueas a placeholder — these handlers are reachable by anyone who can guess aworkflow_run_id. - Transports load credentials lazily — call
load_credentials_for_transportwith thetelephony_configuration_idfrom the workflow run. Don't read the org's default config fromtransport.py. - Logging — use
loguru.logger.
Reference Implementations#
| Provider | Notable 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 |
Use ARI as the smallest viable example when your provider doesn't expose HTTP webhooks, and Twilio as the reference when it does.