Prerequisites

This guide assumes familiarity with two libraries the provider interface is built on:
  • Pipecat — the open-source real-time voice/multimodal pipeline framework EESI’s call pipeline is built on. Its FastAPIWebsocketTransport is what streams audio between the telephony provider and EESI’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 EESI 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 /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.
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 EESI (answer URL, status callbacks, hangup callbacks), expose them through a module-level router. The routes are auto-mounted under /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:

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 /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

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