Feat/inbound telephony (#113)

* feat: inbound telephony (twilio & vobiz)

* chore: add ruff and lint formatting

* fix: add missing cloudonix interface compliance implementation
This commit is contained in:
Sabiha Khan 2026-01-12 10:10:30 +05:30 committed by GitHub
parent b79bc4221d
commit 97fbd9b37b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1998 additions and 40 deletions

View file

@ -0,0 +1,45 @@
"""add call_type column to workflow_runs
Revision ID: b79f19f68157
Revises: 488eb58e4e6e
Create Date: 2026-01-08 21:20:17.298334
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "b79f19f68157"
down_revision: Union[str, None] = "488eb58e4e6e"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create the workflow_call_type enum
sa.Enum("inbound", "outbound", name="workflow_call_type").create(op.get_bind())
# Add call_type column to workflow_runs table
op.add_column(
"workflow_runs",
sa.Column(
"call_type",
postgresql.ENUM(
"inbound", "outbound", name="workflow_call_type", create_type=False
),
server_default=sa.text("'outbound'::workflow_call_type"),
nullable=False,
),
)
def downgrade() -> None:
# Drop the call_type column
op.drop_column("workflow_runs", "call_type")
# Drop the workflow_call_type enum
sa.Enum("inbound", "outbound", name="workflow_call_type").drop(op.get_bind())

View file

@ -51,3 +51,28 @@ SENTRY_DSN = os.getenv("SENTRY_DSN")
ENABLE_ARI_STASIS = os.getenv("ENABLE_ARI_STASIS", "false").lower() == "true"
SERIALIZE_LOG_OUTPUT = os.getenv("SERIALIZE_LOG_OUTPUT", "false").lower() == "true"
ENABLE_TELEMETRY = os.getenv("ENABLE_TELEMETRY", "false").lower() == "true"
# Country code mapping: ISO country code -> international dialing prefix
COUNTRY_CODES = {
"US": "1", # United States
"CA": "1", # Canada
"GB": "44", # United Kingdom
"IN": "91", # India
"AU": "61", # Australia
"DE": "49", # Germany
"FR": "33", # France
"BR": "55", # Brazil
"MX": "52", # Mexico
"IT": "39", # Italy
"ES": "34", # Spain
"NL": "31", # Netherlands
"SE": "46", # Sweden
"NO": "47", # Norway
"DK": "45", # Denmark
"FI": "358", # Finland
"CH": "41", # Switzerland
"AT": "43", # Austria
"BE": "32", # Belgium
"LU": "352", # Luxembourg
"IE": "353", # Ireland
}

View file

@ -21,6 +21,7 @@ from sqlalchemy import (
from sqlalchemy.orm import declarative_base, relationship
from ..enums import (
CallType,
IntegrationAction,
ToolCategory,
ToolStatus,
@ -324,6 +325,12 @@ class WorkflowRunModel(Base):
Enum(*[mode.value for mode in WorkflowRunMode], name="workflow_run_mode"),
nullable=False,
)
call_type = Column(
Enum(*[call_type.value for call_type in CallType], name="workflow_call_type"),
nullable=False,
default=CallType.OUTBOUND.value,
server_default=text("'outbound'::workflow_call_type"),
)
state = Column(
Enum(*[state.value for state in WorkflowRunState], name="workflow_run_state"),
nullable=False,

View file

@ -14,7 +14,7 @@ from api.db.models import (
WorkflowModel,
WorkflowRunModel,
)
from api.enums import StorageBackend
from api.enums import CallType, StorageBackend
from api.schemas.workflow import WorkflowRunResponseSchema
@ -25,6 +25,7 @@ class WorkflowRunClient(BaseDBClient):
workflow_id: int,
mode: str,
user_id: int,
call_type: CallType = CallType.OUTBOUND,
initial_context: dict = None,
campaign_id: int = None,
queued_run_id: int = None,
@ -80,6 +81,7 @@ class WorkflowRunClient(BaseDBClient):
campaign_id=campaign_id,
queued_run_id=queued_run_id,
storage_backend=current_backend.value,
call_type=call_type.value,
)
session.add(new_run)
try:
@ -288,6 +290,7 @@ class WorkflowRunClient(BaseDBClient):
"definition_id": run.definition_id,
"initial_context": run.initial_context,
"gathered_context": run.gathered_context,
"call_type": run.call_type,
}
)
for run in result.scalars().all()

View file

@ -12,6 +12,11 @@ class Environment(Enum):
TEST = "test"
class CallType(Enum):
INBOUND = "inbound"
OUTBOUND = "outbound"
class WorkflowRunMode(Enum):
TWILIO = "twilio"
VONAGE = "vonage"

4
api/errors/__init__.py Normal file
View file

@ -0,0 +1,4 @@
"""
Errors package for the Dograh API.
Contains centralized error definitions and messages for various domains.
"""

View file

@ -0,0 +1,31 @@
"""
Telephony error constants and messages for inbound call validation.
Centralizes error handling across all telephony providers.
"""
from enum import Enum
class TelephonyError(Enum):
"""Telephony validation error types"""
PROVIDER_MISMATCH = "PROVIDER_MISMATCH"
WORKFLOW_NOT_FOUND = "WORKFLOW_NOT_FOUND"
ACCOUNT_VALIDATION_FAILED = "ACCOUNT_VALIDATION_FAILED"
PHONE_NUMBER_NOT_CONFIGURED = "PHONE_NUMBER_NOT_CONFIGURED"
SIGNATURE_VALIDATION_FAILED = "SIGNATURE_VALIDATION_FAILED"
QUOTA_EXCEEDED = "QUOTA_EXCEEDED"
GENERAL_AUTH_FAILED = "GENERAL_AUTH_FAILED"
VALID = "VALID"
# Error messages for organizations (debugging-focused)
TELEPHONY_ERROR_MESSAGES = {
TelephonyError.PROVIDER_MISMATCH: "Configuration error: This phone number is configured for a different telephony provider. Please check your dashboard settings and update your webhook URL configuration.",
TelephonyError.WORKFLOW_NOT_FOUND: "Workflow not found. Please verify the workflow ID in your webhook URL is correct and the workflow exists in your dashboard.",
TelephonyError.ACCOUNT_VALIDATION_FAILED: "Authentication error: Account credentials do not match. Please verify your account SID configuration in the dashboard matches your telephony provider settings.",
TelephonyError.PHONE_NUMBER_NOT_CONFIGURED: "Phone number not configured: This number is not set up for inbound calls in your account. Please add this number to your telephony configuration.",
TelephonyError.SIGNATURE_VALIDATION_FAILED: "Security error: Webhook signature validation failed. Please verify your auth token configuration and ensure requests are coming from your telephony provider.",
TelephonyError.QUOTA_EXCEEDED: "Service temporarily unavailable: Your account has exceeded usage limits. Please contact your administrator or upgrade your plan to continue receiving calls.",
TelephonyError.GENERAL_AUTH_FAILED: "Authentication failed: Please check your webhook URL configuration and ensure your telephony provider settings match your dashboard configuration.",
}

View file

@ -1,27 +1,42 @@
"""
Generic telephony routes that work with any telephony provider.
Telephony routes - handles all telephony-related endpoints.
Consolidated from split modules for easier maintenance.
"""
import json
import random
import uuid
from datetime import UTC, datetime
from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Request, WebSocket
from loguru import logger
from pipecat.utils.context import set_current_run_id
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.future import select
from starlette.responses import HTMLResponse
from api.db import db_client
from api.db.models import UserModel
from api.enums import WorkflowRunState
from api.db.models import OrganizationConfigurationModel, UserModel
from api.db.workflow_client import WorkflowClient
from api.db.workflow_run_client import WorkflowRunClient
from api.enums import CallType, OrganizationConfigurationKey, WorkflowRunState
from api.errors.telephony_errors import TelephonyError
from api.services.auth.depends import get_user
from api.services.campaign.call_dispatcher import campaign_call_dispatcher
from api.services.campaign.campaign_event_publisher import get_campaign_event_publisher
from api.services.quota_service import check_dograh_quota
from api.services.telephony.factory import get_telephony_provider
from api.services.quota_service import check_dograh_quota, check_dograh_quota_by_user_id
from api.services.telephony.factory import (
get_all_telephony_providers,
get_telephony_provider,
)
from api.utils.telephony_helper import (
generic_hangup_response,
normalize_webhook_data,
numbers_match,
parse_webhook_request,
)
from api.utils.tunnel import TunnelURLProvider
from pipecat.utils.context import set_current_run_id
router = APIRouter(prefix="/telephony")
@ -122,15 +137,18 @@ async def initiate_call(
workflow_run_id = request.workflow_run_id
if not workflow_run_id:
workflow_run_name = f"WR-TEL-{random.randint(1000, 9999)}"
numeric_suffix = int(str(uuid.uuid4()).replace("-", "")[:8], 16) % 100000000
workflow_run_name = f"WR-TEL-OUT-{numeric_suffix:08d}"
workflow_run = await db_client.create_workflow_run(
workflow_run_name,
request.workflow_id,
workflow_run_mode,
user_id=user.id,
call_type=CallType.OUTBOUND,
initial_context={
"phone_number": phone_number,
"provider": provider.PROVIDER_NAME,
},
user_id=user.id,
)
workflow_run_id = workflow_run.id
else:
@ -174,6 +192,255 @@ async def initiate_call(
return {"message": f"Call initiated successfully with run name {workflow_run_name}"}
async def _verify_organization_phone_number(
phone_number: str,
organization_id: int,
to_country: str = None,
from_country: str = None,
) -> bool:
"""
Verify that a phone number belongs to the specified organization.
Args:
phone_number: The phone number to verify
organization_id: The organization ID to check against
to_country: ISO country code for the called number (e.g., "US", "IN")
from_country: ISO country code for the caller (e.g., "IN", "GB")
Returns:
True if the phone number belongs to the organization, False otherwise
"""
try:
async with db_client.async_session() as session:
result = await session.execute(
select(OrganizationConfigurationModel).where(
OrganizationConfigurationModel.organization_id == organization_id,
OrganizationConfigurationModel.key
== OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
)
)
config = result.scalars().first()
if not config or not config.value:
logger.warning(
f"No telephony configuration found for organization {organization_id}"
)
return False
from_numbers = config.value.get("from_numbers", [])
logger.debug(
f"Organization {organization_id} has from_numbers: {from_numbers}"
)
for configured_number in from_numbers:
if numbers_match(
phone_number, configured_number, to_country, from_country
):
logger.info(
f"Phone number {phone_number} verified for organization {organization_id} "
f"(matches {configured_number}, to_country={to_country}, from_country={from_country})"
)
return True
logger.warning(
f"Phone number {phone_number} not found in organization {organization_id} from_numbers: {from_numbers} "
f"(to_country={to_country}, from_country={from_country})"
)
return False
except Exception as e:
logger.error(
f"Error verifying phone number {phone_number} for organization {organization_id}: {e}"
)
return False
async def _detect_provider(webhook_data: dict, headers: dict):
"""Detect which telephony provider can handle this webhook"""
provider_classes = await get_all_telephony_providers()
for provider_class in provider_classes:
if provider_class.can_handle_webhook(webhook_data, headers):
return provider_class
logger.warning(f"No provider found for webhook data: {webhook_data.keys()}")
return None
async def _validate_inbound_request(
workflow_id: int,
provider_class,
normalized_data,
webhook_data: dict,
webhook_body: str = "",
x_twilio_signature: str = None,
x_vobiz_signature: str = None,
x_vobiz_timestamp: str = None,
) -> tuple[bool, TelephonyError, dict, object]:
"""
Validate all aspects of inbound request.
Returns: (is_valid, error_type, workflow_context, provider_instance)
"""
workflow = await db_client.get_workflow(workflow_id)
if not workflow:
return False, TelephonyError.WORKFLOW_NOT_FOUND, {}, None
organization_id = workflow.organization_id
user_id = workflow.user_id
provider = normalized_data.provider
# Validate provider and account_id
validation_result = await _validate_organization_provider_config(
organization_id, provider_class, normalized_data.account_id
)
if validation_result != TelephonyError.VALID:
return False, validation_result, {}, None
# Verify phone number belongs to organization
is_valid = await _verify_organization_phone_number(
normalized_data.to_number,
organization_id,
normalized_data.to_country,
normalized_data.from_country,
)
if not is_valid:
return False, TelephonyError.PHONE_NUMBER_NOT_CONFIGURED, {}, None
# Verify webhook signature if provided
provider_instance = None
if x_twilio_signature or x_vobiz_signature:
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
webhook_url = (
f"https://{backend_endpoint}/api/v1/telephony/inbound/{workflow_id}"
)
# Get the real telephony provider with actual credentials for signature verification
provider_instance = await get_telephony_provider(organization_id)
if provider_class.PROVIDER_NAME == "twilio" and x_twilio_signature:
logger.info(f"Verifying Twilio signature for URL: {webhook_url}")
signature_valid = await provider_instance.verify_inbound_signature(
webhook_url, webhook_data, x_twilio_signature
)
elif provider_class.PROVIDER_NAME == "vobiz" and x_vobiz_signature:
logger.info(f"Verifying Vobiz signature for URL: {webhook_url}")
signature_valid = await provider_instance.verify_inbound_signature(
webhook_url,
webhook_data,
x_vobiz_signature,
x_vobiz_timestamp,
webhook_body,
)
else:
logger.warning(
f"No signature validation for provider {provider_class.PROVIDER_NAME}"
)
signature_valid = True
logger.info(f"Signature validation result: {signature_valid}")
if not signature_valid:
return (
False,
TelephonyError.SIGNATURE_VALIDATION_FAILED,
{},
provider_instance,
)
# Return success with workflow context
workflow_context = {
"workflow": workflow,
"organization_id": organization_id,
"user_id": user_id,
"provider": provider,
}
return (
True,
"",
workflow_context,
provider_instance,
) # TODO: do we still need instance in the client code
async def _create_inbound_workflow_run(
workflow_id: int, user_id: int, provider: str, normalized_data, data_source: str
) -> int:
"""Create workflow run for inbound call and return run ID"""
call_id = normalized_data.call_id
numeric_suffix = int(str(uuid.uuid4()).replace("-", "")[:8], 16) % 100000000
workflow_run_name = f"WR-TEL-IN-{numeric_suffix:08d}"
workflow_run = await db_client.create_workflow_run(
workflow_run_name,
workflow_id,
provider, # Use detected provider as mode
user_id=user_id,
call_type=CallType.INBOUND,
initial_context={
"caller_number": normalized_data.from_number,
"called_number": normalized_data.to_number,
"direction": "inbound",
"call_id": call_id,
"account_id": normalized_data.account_id,
"provider": provider,
"data_source": data_source,
"from_country": normalized_data.from_country,
"to_country": normalized_data.to_country,
"raw_webhook_data": normalized_data.raw_data,
},
)
logger.info(
f"Created inbound workflow run {workflow_run.id} for {provider} call {call_id}"
)
return workflow_run.id
async def _validate_organization_provider_config(
organization_id: int, provider_class, account_id: str
) -> TelephonyError:
"""Validate provider and account_id, returning specific error type"""
if not account_id:
logger.warning(
f"No account_id provided for provider {provider_class.PROVIDER_NAME}"
)
return TelephonyError.ACCOUNT_VALIDATION_FAILED
try:
config = await db_client.get_configuration(
organization_id,
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
)
if not config or not config.value:
logger.warning(
f"No telephony configuration found for organization {organization_id}"
)
return TelephonyError.ACCOUNT_VALIDATION_FAILED
stored_provider = config.value.get("provider")
if stored_provider != provider_class.PROVIDER_NAME:
logger.warning(
f"Provider mismatch: webhook={provider_class.PROVIDER_NAME}, config={stored_provider}"
)
return TelephonyError.PROVIDER_MISMATCH
# Use provider-specific validation
is_valid = provider_class.validate_account_id(config.value, account_id)
if not is_valid:
logger.warning(
f"Account validation failed for {provider_class.PROVIDER_NAME}: webhook={account_id}"
)
return TelephonyError.ACCOUNT_VALIDATION_FAILED
return TelephonyError.VALID
except Exception as e:
logger.error(f"Exception during account validation: {e}")
return TelephonyError.ACCOUNT_VALIDATION_FAILED
@router.post("/twiml", include_in_schema=False)
async def handle_twiml_webhook(
workflow_id: int, user_id: int, workflow_run_id: int, organization_id: int
@ -250,8 +517,14 @@ async def websocket_endpoint(
# Extract provider type from workflow run context
provider_type = None
if workflow_run.gathered_context:
provider_type = workflow_run.gathered_context.get("provider")
logger.info(
f"Workflow run {workflow_run_id} gathered_context: {workflow_run.gathered_context}"
)
logger.info(f"Workflow run {workflow_run_id} mode: {workflow_run.mode}")
if workflow_run.initial_context:
provider_type = workflow_run.initial_context.get("provider")
logger.info(f"Extracted provider_type: {provider_type}")
if not provider_type:
logger.error(
@ -546,21 +819,78 @@ async def handle_vobiz_xml_webhook(
async def handle_vobiz_hangup_callback(
workflow_run_id: int,
request: Request,
x_vobiz_signature: Optional[str] = Header(None),
x_vobiz_timestamp: Optional[str] = Header(None),
):
"""Handle Vobiz hangup callback (sent when call ends).
Vobiz sends callbacks to hangup_url when the call terminates.
This includes call duration, status, and billing information.
"""
# TODO: Remove this debug logging after Vobiz team clarifies webhook authentication
# Logging all headers and body to understand what Vobiz actually sends
all_headers = dict(request.headers)
logger.info(
f"[run {workflow_run_id}] Vobiz hangup callback - Headers: {json.dumps(all_headers)}"
)
# Parse the callback data (Vobiz sends form data or JSON)
form_data = await request.form()
callback_data = dict(form_data)
# TODO: Remove this debug logging after Vobiz team clarifies webhook authentication
logger.info(
f"[run {workflow_run_id}] Vobiz hangup callback - Body: {json.dumps(callback_data)}"
)
logger.info(
f"[run {workflow_run_id}] Received Vobiz hangup callback {json.dumps(callback_data)}"
)
# Get workflow run for processing
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
# Verify signature if provided
if x_vobiz_signature:
# We need the workflow run to get organization for provider credentials
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if not workflow_run:
logger.warning(
f"[run {workflow_run_id}] Workflow run not found for signature verification"
)
return {"status": "error", "reason": "workflow_run_not_found"}
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
if not workflow:
logger.warning(
f"[run {workflow_run_id}] Workflow not found for signature verification"
)
return {"status": "error", "reason": "workflow_not_found"}
provider = await get_telephony_provider(workflow.organization_id)
# Get raw body for signature verification
raw_body = await request.body()
webhook_body = raw_body.decode("utf-8")
# Verify signature
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
webhook_url = f"https://{backend_endpoint}/api/v1/telephony/vobiz/hangup-callback/{workflow_run_id}"
is_valid = await provider.verify_webhook_signature(
webhook_url,
callback_data,
x_vobiz_signature,
x_vobiz_timestamp,
webhook_body,
)
if not is_valid:
logger.warning(
f"[run {workflow_run_id}] Invalid Vobiz hangup callback signature"
)
return {"status": "error", "reason": "invalid_signature"}
logger.info(f"[run {workflow_run_id}] Vobiz hangup callback signature verified")
else:
# Get workflow run for processing (signature verification already got it if needed)
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if not workflow_run:
logger.warning(
f"[run {workflow_run_id}] Workflow run not found for Vobiz hangup callback"
@ -609,21 +939,79 @@ async def handle_vobiz_hangup_callback(
async def handle_vobiz_ring_callback(
workflow_run_id: int,
request: Request,
x_vobiz_signature: Optional[str] = Header(None),
x_vobiz_timestamp: Optional[str] = Header(None),
):
"""Handle Vobiz ring callback (sent when call starts ringing).
Vobiz can send callbacks to ring_url when the call starts ringing.
This is optional and used for tracking ringing status.
"""
# TODO: Remove this debug logging after Vobiz team clarifies webhook authentication
# Logging all headers and body to understand what Vobiz actually sends
all_headers = dict(request.headers)
logger.info(
f"[run {workflow_run_id}] Vobiz ring callback - Headers: {json.dumps(all_headers)}"
)
# Parse the callback data
form_data = await request.form()
callback_data = dict(form_data)
# TODO: Remove this debug logging after Vobiz team clarifies webhook authentication
logger.info(
f"[run {workflow_run_id}] Vobiz ring callback - Body: {json.dumps(callback_data)}"
)
logger.info(
f"[run {workflow_run_id}] Received Vobiz ring callback {json.dumps(callback_data)}"
)
# Get workflow run for processing
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
# Verify signature if provided
if x_vobiz_signature:
# We need the workflow run to get organization for provider credentials
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if not workflow_run:
logger.warning(
f"[run {workflow_run_id}] Workflow run not found for signature verification"
)
return {"status": "error", "reason": "workflow_run_not_found"}
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
if not workflow:
logger.warning(
f"[run {workflow_run_id}] Workflow not found for signature verification"
)
return {"status": "error", "reason": "workflow_not_found"}
provider = await get_telephony_provider(workflow.organization_id)
# Get raw body for signature verification
raw_body = await request.body()
webhook_body = raw_body.decode("utf-8")
# Verify signature
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
webhook_url = f"https://{backend_endpoint}/api/v1/telephony/vobiz/ring-callback/{workflow_run_id}"
is_valid = await provider.verify_webhook_signature(
webhook_url,
callback_data,
x_vobiz_signature,
x_vobiz_timestamp,
webhook_body,
)
if not is_valid:
logger.warning(
f"[run {workflow_run_id}] Invalid Vobiz ring callback signature"
)
return {"status": "error", "reason": "invalid_signature"}
logger.info(f"[run {workflow_run_id}] Vobiz ring callback signature verified")
else:
# Get workflow run for processing (signature verification already got it if needed)
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if not workflow_run:
logger.warning(
f"[run {workflow_run_id}] Workflow run not found for Vobiz ring callback"
@ -707,3 +1095,272 @@ async def handle_cloudonix_status_callback(
await _process_status_update(workflow_run_id, status_update, workflow_run)
return {"status": "success"}
@router.post("/vobiz/hangup-callback/workflow/{workflow_id}")
async def handle_vobiz_hangup_callback_by_workflow(
workflow_id: int,
request: Request,
x_vobiz_signature: Optional[str] = Header(None),
x_vobiz_timestamp: Optional[str] = Header(None),
):
"""Handle Vobiz hangup callback with workflow_id - finds workflow run by call_id."""
all_headers = dict(request.headers)
logger.info(
f"[workflow {workflow_id}] Vobiz hangup callback - Headers: {json.dumps(all_headers)}"
)
try:
callback_data, _ = await parse_webhook_request(request)
except ValueError:
callback_data = {}
call_uuid = callback_data.get("CallUUID") or callback_data.get("call_uuid")
logger.info(
f"[workflow {workflow_id}] Received Vobiz hangup callback for call {call_uuid}: {json.dumps(callback_data)}"
)
if not call_uuid:
logger.warning(
f"[workflow {workflow_id}] No call_uuid found in Vobiz hangup callback"
)
return {"status": "error", "message": "No call_uuid found"}
workflow_client = WorkflowClient()
workflow = await workflow_client.get_workflow_by_id(workflow_id)
if not workflow:
logger.warning(f"[workflow {workflow_id}] Workflow not found")
return {"status": "error", "message": "workflow_not_found"}
provider = await get_telephony_provider(workflow.organization_id)
if x_vobiz_signature:
raw_body = await request.body()
webhook_body = raw_body.decode("utf-8")
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
webhook_url = f"https://{backend_endpoint}/api/v1/telephony/vobiz/hangup-callback/workflow/{workflow_id}"
is_valid = await provider.verify_webhook_signature(
webhook_url,
callback_data,
x_vobiz_signature,
x_vobiz_timestamp,
webhook_body,
)
if not is_valid:
logger.warning(
f"[workflow {workflow_id}] Invalid Vobiz hangup callback signature"
)
return {"status": "error", "message": "invalid_signature"}
logger.info(
f"[workflow {workflow_id}] Vobiz hangup callback signature verified"
)
try:
db_client = WorkflowRunClient()
async with db_client.async_session() as session:
# Fetch workflow run with matching call_id in initial_context
query = text("""
SELECT id FROM workflow_runs
WHERE workflow_id = :workflow_id
AND CAST(initial_context AS jsonb) @> CAST(:call_id_json AS jsonb)
ORDER BY created_at DESC
LIMIT 1
""")
result = await session.execute(
query,
{
"workflow_id": workflow_id,
"call_id_json": json.dumps({"call_id": call_uuid}),
},
)
workflow_run_row = result.fetchone()
if not workflow_run_row:
logger.warning(
f"[workflow {workflow_id}] No workflow run found for call {call_uuid}"
)
return {"status": "ignored", "reason": "workflow_run_not_found"}
workflow_run_id = workflow_run_row[0]
logger.info(
f"[workflow {workflow_id}] Found workflow run {workflow_run_id} for call {call_uuid}"
)
except Exception as e:
logger.error(
f"[workflow {workflow_id}] Error finding workflow run for call {call_uuid}: {e}"
)
return {"status": "error", "message": str(e)}
try:
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if not workflow_run:
logger.warning(f"[run {workflow_run_id}] Workflow run not found")
return {"status": "ignored", "reason": "workflow_run_not_found"}
parsed_data = provider.parse_status_callback(callback_data)
status = StatusCallbackRequest(
call_id=parsed_data["call_id"],
status=parsed_data["status"],
from_number=parsed_data.get("from_number"),
to_number=parsed_data.get("to_number"),
direction=parsed_data.get("direction"),
duration=parsed_data.get("duration"),
extra=parsed_data.get("extra", {}),
)
await _process_status_update(workflow_run_id, status, workflow_run)
logger.info(
f"[run {workflow_run_id}] Vobiz hangup callback processed successfully"
)
return {"status": "success"}
except Exception as e:
logger.error(
f"[run {workflow_run_id}] Error processing Vobiz hangup callback: {e}"
)
return {"status": "error", "message": str(e)}
@router.post("/inbound/{workflow_id}")
async def handle_inbound_telephony(
workflow_id: int,
request: Request,
x_twilio_signature: Optional[str] = Header(None),
x_vobiz_signature: Optional[str] = Header(None),
x_vobiz_timestamp: Optional[str] = Header(None),
):
"""Handle inbound telephony calls from any supported provider with common processing"""
logger.info(f"Inbound call received for workflow_id: {workflow_id}")
try:
webhook_data, data_source = await parse_webhook_request(request)
headers = dict(request.headers)
# Detect provider and normalize data
provider_class = await _detect_provider(webhook_data, headers)
if not provider_class:
logger.error("Unable to detect provider for webhook")
return generic_hangup_response()
normalized_data = normalize_webhook_data(provider_class, webhook_data)
logger.info(
f"Inbound call - Provider: {normalized_data.provider}, Data source: {data_source}"
)
logger.info(f"Normalized data: {normalized_data}")
# Validate inbound direction
if normalized_data.direction != "inbound":
logger.warning(f"Non-inbound call received: {normalized_data.direction}")
return generic_hangup_response()
logger.info(f"Inbound call headers: {dict(request.headers)}")
logger.info(f"Twilio signature header: {x_twilio_signature}")
logger.info(f"Vobiz signature header: {x_vobiz_signature}")
logger.info(f"Vobiz timestamp header: {x_vobiz_timestamp}")
webhook_body = ""
if provider_class.PROVIDER_NAME == "vobiz":
webhook_body = data_source
logger.info(f"Vobiz inbound call - Body: {json.dumps(webhook_data)}")
(
is_valid,
error_type,
workflow_context,
provider_instance,
) = await _validate_inbound_request(
workflow_id,
provider_class,
normalized_data,
webhook_data,
webhook_body,
x_twilio_signature,
x_vobiz_signature,
x_vobiz_timestamp,
)
if not is_valid:
logger.error(f"Request validation failed: {error_type}")
return provider_class.generate_validation_error_response(error_type)
# Check quota before processing
user_id = workflow_context["user_id"]
quota_result = await check_dograh_quota_by_user_id(user_id)
if not quota_result.has_quota:
logger.warning(
f"User {user_id} has exceeded quota for inbound calls: {quota_result.error_message}"
)
return provider_class.generate_validation_error_response(
TelephonyError.QUOTA_EXCEEDED
)
# Create workflow run
workflow_run_id = await _create_inbound_workflow_run(
workflow_id,
workflow_context["user_id"],
workflow_context["provider"],
normalized_data,
data_source,
)
# Generate response URLs
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
websocket_url = f"wss://{backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{workflow_context['user_id']}/{workflow_run_id}"
response = await provider_class.generate_inbound_response(
websocket_url, workflow_run_id
)
logger.info(
f"Generated {normalized_data.provider} response for call {normalized_data.call_id}"
)
return response
except ValueError as e:
logger.error(f"Request parsing error: {e}")
return generic_hangup_response()
except Exception as e:
logger.error(f"Error processing inbound call: {e}")
return generic_hangup_response()
@router.post("/inbound/fallback")
async def handle_inbound_fallback(request: Request):
"""Fallback endpoint that returns audio message when calls cannot be processed."""
webhook_data, _ = await parse_webhook_request(request)
headers = dict(request.headers)
# Detect provider
provider_class = await _detect_provider(webhook_data, headers)
if provider_class:
# Use provider-specific error response
call_id = (
webhook_data.get("CallSid")
or webhook_data.get("CallUUID")
or webhook_data.get("call_uuid")
)
logger.info(
f"[fallback] Received {provider_class.PROVIDER_NAME} callback for call {call_id}: {json.dumps(webhook_data)}"
)
return provider_class.generate_error_response(
"SYSTEM_UNAVAILABLE",
"Our system is temporarily unavailable. Please try again later.",
)
else:
# Unknown provider - return generic XML
logger.info(
f"[fallback] Received unknown provider callback: {json.dumps(webhook_data)} and request headers: {json.dumps(headers)}"
)
return generic_hangup_response()

View file

@ -12,6 +12,7 @@ from api.constants import DEPLOYMENT_MODE
from api.db import db_client
from api.db.models import UserModel
from api.db.workflow_template_client import WorkflowTemplateClient
from api.enums import CallType
from api.schemas.workflow import WorkflowRunResponseSchema
from api.services.auth.depends import get_user
from api.services.mps_service_key_client import mps_service_key_client
@ -141,7 +142,7 @@ class CreateWorkflowRunResponse(BaseModel):
class CreateWorkflowTemplateRequest(BaseModel):
call_type: Literal["INBOUND", "OUTBOUND"]
call_type: Literal[CallType.INBOUND.value, CallType.OUTBOUND.value]
use_case: str
activity_description: str
@ -289,7 +290,7 @@ async def create_workflow_from_template(
# Call MPS API to generate workflow using the client
if DEPLOYMENT_MODE == "oss":
workflow_data = await mps_service_key_client.call_workflow_api(
call_type=request.call_type,
call_type=request.call_type.upper(),
use_case=request.use_case,
activity_description=request.activity_description,
created_by=str(user.provider_id),
@ -299,7 +300,7 @@ async def create_workflow_from_template(
raise HTTPException(status_code=400, detail="No organization selected")
workflow_data = await mps_service_key_client.call_workflow_api(
call_type=request.call_type,
call_type=request.call_type.upper(),
use_case=request.use_case,
activity_description=request.activity_description,
organization_id=user.selected_organization_id,
@ -609,6 +610,7 @@ async def get_workflow_run(
"definition_id": run.definition_id,
"initial_context": run.initial_context,
"gathered_context": run.gathered_context,
"call_type": run.call_type,
}

View file

@ -3,6 +3,8 @@ from typing import Any, Dict
from pydantic import BaseModel
from api.enums import CallType
class WorkflowRunResponseSchema(BaseModel):
id: int
@ -17,3 +19,4 @@ class WorkflowRunResponseSchema(BaseModel):
definition_id: int | None # This is for backward compatibility
initial_context: dict | None = None
gathered_context: dict | None = None
call_type: CallType

View file

@ -26,6 +26,22 @@ class CallInitiationResult:
) # Full provider response for debugging
@dataclass
class NormalizedInboundData:
"""Standardized inbound call data across all providers."""
provider: str # Provider name (twilio, vobiz, etc.)
call_id: str # Provider's call identifier
from_number: str # Caller phone number (E.164 format)
to_number: str # Called phone number (E.164 format)
direction: str # Call direction (should be "inbound")
call_status: str # Call status (ringing, answered, etc.)
account_id: Optional[str] = None # Provider account ID
from_country: Optional[str] = None # Country code of caller
to_country: Optional[str] = None # Country code of called number
raw_data: Dict[str, Any] = field(default_factory=dict) # Original webhook data
class TelephonyProvider(ABC):
"""
Abstract base class for telephony providers.
@ -181,3 +197,109 @@ class TelephonyProvider(ABC):
workflow_run_id: The workflow run ID
"""
pass
# ======== INBOUND CALL METHODS ========
@classmethod
@abstractmethod
def can_handle_webhook(
cls, webhook_data: Dict[str, Any], headers: Dict[str, str]
) -> bool:
"""
Determine if this provider can handle the incoming webhook.
Args:
webhook_data: The parsed webhook payload
headers: HTTP headers from the webhook request
Returns:
True if this provider should handle this webhook, False otherwise
"""
pass
@staticmethod
@abstractmethod
def parse_inbound_webhook(webhook_data: Dict[str, Any]) -> NormalizedInboundData:
"""
Parse provider-specific inbound webhook data into normalized format.
Args:
webhook_data: Raw webhook data from the provider
Returns:
NormalizedInboundData with standardized fields
"""
pass
@staticmethod
@abstractmethod
def validate_account_id(config_data: dict, webhook_account_id: str) -> bool:
"""
Validate that the account_id from webhook matches the provider configuration.
Args:
config_data: Provider configuration data from organization
webhook_account_id: Account ID from the webhook
Returns:
True if account_id matches, False otherwise
"""
pass
@abstractmethod
def normalize_phone_number(self, phone_number: str) -> str:
"""
Normalize a phone number to E.164 format for this provider.
Args:
phone_number: Raw phone number from webhook
Returns:
Phone number in E.164 format (+country_code_number)
"""
pass
@abstractmethod
async def verify_inbound_signature(
self, url: str, webhook_data: Dict[str, Any], signature: str
) -> bool:
"""
Verify the signature of an inbound webhook for security.
Args:
url: The full webhook URL
webhook_data: The webhook payload
signature: The signature header from the provider
Returns:
True if signature is valid, False otherwise
"""
pass
@abstractmethod
def generate_inbound_response(self, websocket_url: str) -> tuple:
"""
Generate the appropriate response for an inbound webhook.
Args:
websocket_url: WebSocket URL for audio streaming
Returns:
Tuple of (Response, media_type) - Response object and content type
"""
pass
@staticmethod
@abstractmethod
def generate_error_response(error_type: str, message: str) -> tuple:
"""
Generate a provider-specific error response.
Args:
error_type: Type of error (auth_failed, not_configured, etc.)
message: Error message
Returns:
Tuple of (Response, media_type) - Response object and content type
"""
pass

View file

@ -4,7 +4,7 @@ Handles configuration loading from environment (OSS) or database (SaaS).
The providers themselves don't know or care where config comes from.
"""
from typing import Any, Dict
from typing import Any, Dict, List, Type
from loguru import logger
@ -116,3 +116,13 @@ async def get_telephony_provider(organization_id: int) -> TelephonyProvider:
else:
raise ValueError(f"Unknown telephony provider: {provider_type}")
async def get_all_telephony_providers() -> List[Type[TelephonyProvider]]:
"""
Get all available telephony provider classes for webhook detection.
Returns:
List of provider classes that can be used for webhook detection
"""
return [TwilioProvider, VobizProvider, VonageProvider]

View file

@ -10,7 +10,11 @@ import aiohttp
from loguru import logger
from api.enums import WorkflowRunMode
from api.services.telephony.base import CallInitiationResult, TelephonyProvider
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
TelephonyProvider,
)
from api.utils.tunnel import TunnelURLProvider
if TYPE_CHECKING:
@ -416,3 +420,170 @@ class CloudonixProvider(TelephonyProvider):
except Exception as e:
logger.error(f"Error in Cloudonix WebSocket handler: {e}")
raise
# ======== INBOUND CALL METHODS ========
@classmethod
def can_handle_webhook(
cls, webhook_data: Dict[str, Any], headers: Dict[str, str]
) -> bool:
"""
Determine if this provider can handle the incoming webhook.
Cloudonix uses TwiML-compatible format, so look for Twilio-like identifiers
but also check for Cloudonix-specific headers or fields if they exist.
"""
# Check for Cloudonix-specific headers
if headers.get("User-Agent", "").lower().startswith("cloudonix"):
return True
# Check for session token (Cloudonix equivalent of CallSid)
if "token" in webhook_data or "session_token" in webhook_data:
return True
# If it looks like TwiML format but no other providers claimed it,
# it could be Cloudonix (TwiML-compatible)
if "CallSid" in webhook_data and "AccountSid" in webhook_data:
# Let Twilio provider handle this first, only handle if unclaimed
return False
return False
@staticmethod
def parse_inbound_webhook(webhook_data: Dict[str, Any]) -> NormalizedInboundData:
"""
Parse Cloudonix-specific inbound webhook data into normalized format.
Cloudonix is TwiML-compatible so the webhook format should be similar to Twilio.
"""
return NormalizedInboundData(
provider=CloudonixProvider.PROVIDER_NAME,
call_id=webhook_data.get("token") or webhook_data.get("CallSid", ""),
from_number=webhook_data.get("From", ""),
to_number=webhook_data.get("To", ""),
direction="inbound", # This is an inbound webhook
call_status=webhook_data.get("CallStatus", "ringing"),
account_id=webhook_data.get("AccountSid") or webhook_data.get("domain_id"),
from_country=webhook_data.get("FromCountry"),
to_country=webhook_data.get("ToCountry"),
raw_data=webhook_data,
)
@staticmethod
def validate_account_id(config_data: dict, webhook_account_id: str) -> bool:
"""
Validate that the account_id from webhook matches the Cloudonix configuration.
"""
if not webhook_account_id:
return False
# Cloudonix uses domain_id as the account identifier
stored_domain_id = config_data.get("domain_id")
if not stored_domain_id:
return False
return webhook_account_id == stored_domain_id
def normalize_phone_number(self, phone_number: str) -> str:
"""
Normalize a phone number to E.164 format for Cloudonix.
Cloudonix typically provides numbers in E.164 format already,
but we'll ensure proper formatting.
"""
if not phone_number:
return ""
# Remove any spaces or formatting
clean_number = (
phone_number.replace(" ", "")
.replace("-", "")
.replace("(", "")
.replace(")", "")
)
# If already in E.164 format (+...), return as-is
if clean_number.startswith("+"):
return clean_number
# If starts with country code but no +, add it
if len(clean_number) >= 10:
return f"+{clean_number}"
return clean_number
async def verify_inbound_signature(
self, url: str, webhook_data: Dict[str, Any], signature: str
) -> bool:
"""
Verify the signature of an inbound Cloudonix webhook for security.
Note: Cloudonix signature verification details need to be implemented
based on their specific authentication method. For now, we'll log
and return True (similar to current webhook verification).
"""
logger.info(
f"Cloudonix inbound signature verification not fully implemented. "
f"Webhook URL: {url}, Signature present: {bool(signature)}"
)
# TODO: Implement actual Cloudonix signature verification
# This would depend on Cloudonix's specific signing method
return True
def generate_inbound_response(self, websocket_url: str) -> tuple:
"""
Generate the appropriate TwiML response for an inbound Cloudonix webhook.
Since Cloudonix is TwiML-compatible, we generate TwiML response.
"""
from fastapi import Response
# Generate TwiML response to connect to WebSocket
twiml = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Connect>
<Stream url="{websocket_url}"></Stream>
</Connect>
<Pause length="40"/>
</Response>"""
return Response(content=twiml, media_type="application/xml"), "application/xml"
@staticmethod
def generate_error_response(error_type: str, message: str) -> tuple:
"""
Generate a Cloudonix-specific error response.
Since Cloudonix is TwiML-compatible, we use TwiML format.
"""
from fastapi import Response
# Map error types to appropriate TwiML responses
if error_type == "auth_failed":
twiml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say>Authentication failed. This call cannot be processed.</Say>
<Hangup/>
</Response>"""
elif error_type == "not_configured":
twiml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say>Service not configured. Please contact support.</Say>
<Hangup/>
</Response>"""
elif error_type == "invalid_number":
twiml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say>Invalid phone number. This call cannot be processed.</Say>
<Hangup/>
</Response>"""
else:
# Generic error
twiml = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say>An error occurred: {message}</Say>
<Hangup/>
</Response>"""
return Response(content=twiml, media_type="application/xml"), "application/xml"

View file

@ -11,7 +11,11 @@ from loguru import logger
from twilio.request_validator import RequestValidator
from api.enums import WorkflowRunMode
from api.services.telephony.base import CallInitiationResult, TelephonyProvider
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
TelephonyProvider,
)
from api.utils.tunnel import TunnelURLProvider
if TYPE_CHECKING:
@ -284,3 +288,139 @@ class TwilioProvider(TelephonyProvider):
except Exception as e:
logger.error(f"Error in Twilio WebSocket handler: {e}")
raise
# ======== INBOUND CALL METHODS ========
@classmethod
def can_handle_webhook(
cls, webhook_data: Dict[str, Any], headers: Dict[str, str]
) -> bool:
"""
Determine if this provider can handle the incoming webhook.
Twilio webhooks contain CallSid field.
"""
return "CallSid" in webhook_data
@staticmethod
def parse_inbound_webhook(webhook_data: Dict[str, Any]) -> NormalizedInboundData:
"""
Parse Twilio-specific inbound webhook data into normalized format.
"""
return NormalizedInboundData(
provider=TwilioProvider.PROVIDER_NAME,
call_id=webhook_data.get("CallSid", ""),
from_number=TwilioProvider.normalize_phone_number(
webhook_data.get("From", "")
),
to_number=TwilioProvider.normalize_phone_number(webhook_data.get("To", "")),
direction=webhook_data.get("Direction", ""),
call_status=webhook_data.get("CallStatus", ""),
account_id=webhook_data.get("AccountSid"),
from_country=webhook_data.get("FromCountry")
or webhook_data.get("CallerCountry"),
to_country=webhook_data.get("ToCountry")
or webhook_data.get("CalledCountry"),
raw_data=webhook_data,
)
@staticmethod
def normalize_phone_number(phone_number: str) -> str:
"""
Normalize a phone number to E.164 format for Twilio.
Twilio already provides numbers in E.164 format.
"""
if not phone_number:
return ""
# Twilio numbers are already in E.164 format (+1234567890)
if phone_number.startswith("+"):
return phone_number
# If for some reason it doesn't have +, assume US and add +1
if phone_number.startswith("1") and len(phone_number) == 11:
return f"+{phone_number}"
elif len(phone_number) == 10:
return f"+1{phone_number}"
return phone_number
@staticmethod
def validate_account_id(config_data: dict, webhook_account_id: str) -> bool:
"""Validate Twilio account_sid from webhook matches configuration"""
if not webhook_account_id:
return False
stored_account_sid = config_data.get("account_sid")
return stored_account_sid == webhook_account_id
async def verify_inbound_signature(
self, url: str, webhook_data: Dict[str, Any], signature: str
) -> bool:
"""
Verify the signature of an inbound Twilio webhook for security.
"""
return await self.verify_webhook_signature(url, webhook_data, signature)
@staticmethod
async def generate_inbound_response(
websocket_url: str, workflow_run_id: int = None
) -> tuple:
"""
Generate TwiML response for an inbound Twilio webhook.
Uses the same StatusCallback URL pattern as outbound calls for consistency.
"""
from fastapi import Response
# Generate StatusCallback URL using same pattern as outbound calls
status_callback_attr = ""
if workflow_run_id:
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
status_callback_url = f"https://{backend_endpoint}/api/v1/telephony/twilio/status-callback/{workflow_run_id}"
status_callback_attr = f' statusCallback="{status_callback_url}"'
twiml_content = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Connect>
<Stream url="{websocket_url}"{status_callback_attr}></Stream>
</Connect>
<Pause length="40"/>
</Response>"""
return Response(content=twiml_content, media_type="application/xml")
@staticmethod
def generate_error_response(error_type: str, message: str) -> tuple:
"""
Generate a Twilio-specific error response.
"""
from fastapi import Response
twiml_content = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="alice">Sorry, there was an error processing your call. {message}</Say>
<Hangup/>
</Response>"""
return Response(content=twiml_content, media_type="application/xml")
@staticmethod
def generate_validation_error_response(error_type) -> tuple:
"""
Generate Twilio-specific error response for validation failures with organizational debugging info.
"""
from fastapi import Response
from api.errors.telephony_errors import TELEPHONY_ERROR_MESSAGES, TelephonyError
message = TELEPHONY_ERROR_MESSAGES.get(
error_type, TELEPHONY_ERROR_MESSAGES[TelephonyError.GENERAL_AUTH_FAILED]
)
twiml_content = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="alice">{message}</Say>
<Hangup/>
</Response>"""
return Response(content=twiml_content, media_type="application/xml")

View file

@ -10,7 +10,11 @@ import aiohttp
from loguru import logger
from api.enums import WorkflowRunMode
from api.services.telephony.base import CallInitiationResult, TelephonyProvider
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
TelephonyProvider,
)
from api.utils.tunnel import TunnelURLProvider
if TYPE_CHECKING:
@ -179,20 +183,65 @@ class VobizProvider(TelephonyProvider):
return bool(self.auth_id and self.auth_token and self.from_numbers)
async def verify_webhook_signature(
self, url: str, params: Dict[str, Any], signature: str
self,
url: str,
params: Dict[str, Any],
signature: str,
timestamp: str = None,
body: str = "",
) -> bool:
"""
Verify Vobiz webhook signature for security.
Vobiz uses Plivo-compatible signature verification (HMAC-SHA256).
For now, returning True to allow testing.
TODO: Implement proper signature verification based on Vobiz docs.
Vobiz uses HMAC-SHA256 signature verification with timestamp validation:
- Header: x-vobiz-signature (HMAC-SHA256 hash)
- Header: x-vobiz-timestamp (timestamp for replay protection)
- Signature = HMAC-SHA256(auth_token, timestamp + '.' + body)
"""
# Plivo/Vobiz signature verification would go here
# For development, we can skip signature verification
# In production, implement HMAC-SHA256 verification
logger.warning("Vobiz webhook signature verification not yet implemented")
return True
import hashlib
import hmac
from datetime import datetime, timezone
if not signature or not timestamp:
logger.warning("Missing signature or timestamp headers for Vobiz webhook")
return False
if not self.auth_token:
logger.error(
"No auth_token available for Vobiz webhook signature verification"
)
return False
try:
# 1. Timestamp validation (within 5 minutes)
webhook_timestamp = int(timestamp)
current_timestamp = int(datetime.now(timezone.utc).timestamp())
time_diff = abs(current_timestamp - webhook_timestamp)
if time_diff > 300: # 5 minutes = 300 seconds
logger.warning(f"Vobiz webhook timestamp too old: {time_diff}s > 300s")
return False
# 2. Signature verification
# Create expected signature: HMAC-SHA256(auth_token, timestamp + '.' + body)
payload = f"{timestamp}.{body}"
expected_signature = hmac.new(
self.auth_token.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256
).hexdigest()
# 3. Compare signatures (timing-safe comparison)
is_valid = hmac.compare_digest(expected_signature, signature)
if not is_valid:
logger.warning(
f"Vobiz webhook signature mismatch. Expected: {expected_signature[:8]}..., Got: {signature[:8]}..."
)
return is_valid
except Exception as e:
logger.error(f"Error verifying Vobiz webhook signature: {e}")
return False
async def get_webhook_response(
self, workflow_id: int, user_id: int, workflow_run_id: int
@ -339,3 +388,140 @@ class VobizProvider(TelephonyProvider):
f"[run {workflow_run_id}] Error in Vobiz WebSocket handler: {e}"
)
raise
# ======== INBOUND CALL METHODS ========
@classmethod
def can_handle_webhook(
cls, webhook_data: Dict[str, Any], headers: Dict[str, str]
) -> bool:
"""
Determine if this provider can handle the incoming webhook.
Vobiz webhooks contain CallUUID field.
"""
return "CallUUID" in webhook_data
@staticmethod
def parse_inbound_webhook(webhook_data: Dict[str, Any]) -> NormalizedInboundData:
"""
Parse Vobiz-specific inbound webhook data into normalized format.
"""
return NormalizedInboundData(
provider=VobizProvider.PROVIDER_NAME,
call_id=webhook_data.get("CallUUID", ""),
from_number=VobizProvider.normalize_phone_number(
webhook_data.get("From", "")
),
to_number=VobizProvider.normalize_phone_number(webhook_data.get("To", "")),
direction=webhook_data.get("Direction", ""),
call_status=webhook_data.get("CallStatus", ""),
account_id=webhook_data.get("ParentAuthID"),
from_country=None, # Vobiz doesn't provide country information
to_country=None, # Vobiz doesn't provide country information
raw_data=webhook_data,
)
@staticmethod
def normalize_phone_number(phone_number: str) -> str:
"""
Normalize a phone number to E.164 format for Vobiz.
Vobiz sends numbers in various formats - normalize to E.164 with +.
"""
if not phone_number:
return ""
# Remove any existing + prefix
clean_number = phone_number.lstrip("+")
# If it starts with 1 and has 11 digits, it's a US number
if clean_number.startswith("1") and len(clean_number) == 11:
return f"+{clean_number}"
elif len(clean_number) == 10:
# Assume US number if 10 digits
return f"+1{clean_number}"
elif len(clean_number) > 10:
# International number without country code detection
return f"+{clean_number}"
return phone_number
@staticmethod
def validate_account_id(config_data: dict, webhook_account_id: str) -> bool:
"""Validate Vobiz auth_id from webhook matches configuration"""
if not webhook_account_id:
return False
stored_auth_id = config_data.get("auth_id")
return stored_auth_id == webhook_account_id
async def verify_inbound_signature(
self,
url: str,
webhook_data: Dict[str, Any],
signature: str,
timestamp: str = None,
body: str = "",
) -> bool:
"""
Verify the signature of an inbound Vobiz webhook for security.
Uses the same HMAC-SHA256 verification as other Vobiz webhooks.
"""
return await self.verify_webhook_signature(
url, webhook_data, signature, timestamp, body
)
@staticmethod
async def generate_inbound_response(
websocket_url: str, workflow_run_id: int = None
) -> tuple:
"""
Generate Vobiz XML response for an inbound webhook.
Note: For hangup callbacks, configure the hangup_url manually in Vobiz dashboard
to point to: /api/v1/telephony/vobiz/hangup-callback/workflow/{workflow_id}
"""
from fastapi import Response
vobiz_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Stream bidirectional="true" keepCallAlive="true" contentType="audio/x-mulaw;rate=8000">{websocket_url}</Stream>
</Response>"""
return Response(content=vobiz_xml, media_type="application/xml")
@staticmethod
def generate_error_response(error_type: str, message: str) -> tuple:
"""
Generate a Vobiz-specific error response.
"""
from fastapi import Response
# Vobiz error responses should be valid XML like Plivo
vobiz_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Speak voice="WOMAN">Sorry, there was an error processing your call. {message}</Speak>
<Hangup/>
</Response>"""
return Response(content=vobiz_xml, media_type="application/xml")
@staticmethod
def generate_validation_error_response(error_type) -> tuple:
"""
Generate Vobiz-specific error response for validation failures with organizational debugging info.
"""
from fastapi import Response
from api.errors.telephony_errors import TELEPHONY_ERROR_MESSAGES, TelephonyError
message = TELEPHONY_ERROR_MESSAGES.get(
error_type, TELEPHONY_ERROR_MESSAGES[TelephonyError.GENERAL_AUTH_FAILED]
)
vobiz_xml_content = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Speak voice="WOMAN">{message}</Speak>
<Hangup/>
</Response>"""
return Response(content=vobiz_xml_content, media_type="application/xml")

View file

@ -9,10 +9,15 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional
import aiohttp
import jwt
from fastapi import Response
from loguru import logger
from api.enums import WorkflowRunMode
from api.services.telephony.base import CallInitiationResult, TelephonyProvider
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
TelephonyProvider,
)
from api.utils.tunnel import TunnelURLProvider
if TYPE_CHECKING:
@ -378,3 +383,99 @@ class VonageProvider(TelephonyProvider):
except Exception as e:
logger.error(f"Error in Vonage WebSocket handler: {e}")
raise
# ======== INBOUND CALL METHODS ========
@classmethod
def can_handle_webhook(
cls, webhook_data: Dict[str, Any], headers: Dict[str, str]
) -> bool:
"""
Determine if this provider can handle the incoming webhook.
"""
return False
@staticmethod
def parse_inbound_webhook(webhook_data: Dict[str, Any]) -> NormalizedInboundData:
"""
Parse Vonage-specific inbound webhook data into normalized format.
"""
return NormalizedInboundData(
provider=VonageProvider.PROVIDER_NAME,
call_id=webhook_data.get("uuid", ""),
from_number=webhook_data.get("from", ""),
to_number=webhook_data.get("to", ""),
direction=webhook_data.get("direction", ""),
call_status=webhook_data.get("status", ""),
account_id=webhook_data.get("account_id"),
from_country=None,
to_country=None,
raw_data=webhook_data,
)
@staticmethod
def normalize_phone_number(phone_number: str) -> str:
"""
Normalize a phone number to E.164 format for Vonage.
"""
if not phone_number:
return ""
if phone_number.startswith("+"):
return phone_number
return f"+{phone_number}"
@staticmethod
def validate_account_id(config_data: dict, webhook_account_id: str) -> bool:
"""Validate Vonage account_id from webhook matches configuration"""
if not webhook_account_id:
return False
stored_api_key = config_data.get("api_key")
return stored_api_key == webhook_account_id
async def verify_inbound_signature(
self, url: str, webhook_data: Dict[str, Any], signature: str
) -> bool:
"""
Vonage inbound signature verification - minimalist implementation.
"""
return True
@staticmethod
async def generate_inbound_response(
websocket_url: str, workflow_run_id: int = None
) -> tuple:
"""
Generate NCCO response for inbound Vonage webhook.
"""
# Minimalist NCCO response for interface compliance
ncco_response = [
{
"action": "talk",
"text": "Vonage inbound calls are not currently supported.",
},
{"action": "hangup"},
]
return Response(
content=json.dumps(ncco_response), media_type="application/json"
)
@staticmethod
def generate_error_response(error_type: str, message: str) -> tuple:
"""
Generate a Vonage-specific error response.
"""
from fastapi import Response
error_ncco = [
{
"action": "talk",
"text": f"Sorry, there was an error processing your call. {message}",
},
{"action": "hangup"},
]
return Response(content=json.dumps(error_ncco), media_type="application/json")

View file

@ -0,0 +1,232 @@
"""
Telephony helper utilities.
Common functions used across telephony operations.
"""
from dograh.api.constants import COUNTRY_CODES
from fastapi import Request
from loguru import logger
from starlette.responses import HTMLResponse
def numbers_match(
incoming_number: str,
configured_number: str,
to_country: str = None,
from_country: str = None,
) -> bool:
"""
Check if two phone numbers match, handling different formats with country context.
Args:
incoming_number: Phone number from webhook
configured_number: Phone number from organization config
to_country: ISO country code for the called number (e.g., "US", "IN")
from_country: ISO country code for the caller (e.g., "IN", "GB")
Examples:
- incoming: "+08043071383", configured: "918043071383", to_country="IN" -> True
- incoming: "+918043071383", configured: "918043071383" -> True
- incoming: "+19781899185", configured: "+19781899185" -> True
"""
if not incoming_number or not configured_number:
return False
# Remove spaces and normalize
incoming_clean = incoming_number.replace(" ", "").replace("-", "")
configured_clean = configured_number.replace(" ", "").replace("-", "")
# Direct match
if incoming_clean == configured_clean:
return True
# Remove + from both and compare
incoming_no_plus = incoming_clean.lstrip("+")
configured_no_plus = configured_clean.lstrip("+")
if incoming_no_plus == configured_no_plus:
return True
if to_country:
country_code = get_country_code(to_country)
if country_code:
if _test_number_formats_with_country_code(
incoming_no_plus, configured_no_plus, country_code
):
return True
# Fallback to caller country if available
if from_country and from_country != to_country:
country_code = get_country_code(from_country)
if country_code:
if _test_number_formats_with_country_code(
incoming_no_plus, configured_no_plus, country_code
):
return True
# Legacy fallback for common country codes (when no country info available)
if not to_country and not from_country:
common_codes = ["91", "1", "44"] # India, US/Canada, UK
for code in common_codes:
if _test_number_formats_with_country_code(
incoming_no_plus, configured_no_plus, code
):
return True
return False
def _test_number_formats_with_country_code(
incoming_no_plus: str, configured_no_plus: str, country_code: str
) -> bool:
"""
Test different phone number format variations with the given country code to find matches.
This function handles various international phone number formatting scenarios:
- Numbers with/without country codes
- Numbers with leading zeros vs country codes
- Different representations of the same number across formats
Args:
incoming_no_plus: Incoming number without + prefix
configured_no_plus: Configured number without + prefix
country_code: International dialing code (e.g., "91", "1")
Returns:
True if any format variation produces a match
"""
# Case 1: Incoming has no country code, configured has it
if f"{country_code}{incoming_no_plus}" == configured_no_plus:
return True
# Case 2: Incoming has leading 0, need to replace with country code
if incoming_no_plus.startswith("0"):
local_part = incoming_no_plus[1:] # Remove leading 0
if f"{country_code}{local_part}" == configured_no_plus:
return True
# Case 3: Configured has no country code, incoming has it
if f"{country_code}{configured_no_plus}" == incoming_no_plus:
return True
# Case 4: Configured has leading 0, need to replace with country code
if configured_no_plus.startswith("0"):
local_part = configured_no_plus[1:] # Remove leading 0
if f"{country_code}{local_part}" == incoming_no_plus:
return True
return False
def normalize_phone_number(phone_number: str, country_code: str = None) -> str:
"""
Normalize a phone number to E.164 format using country context.
Args:
phone_number: Phone number to normalize
country_code: ISO country code (e.g., "US", "IN") for context
Returns:
Phone number in E.164 format (e.g., "+14155552671", "+919876543210")
"""
if not phone_number:
return ""
# Remove spaces, hyphens, and other formatting
clean_number = (
phone_number.replace(" ", "").replace("-", "").replace("(", "").replace(")", "")
)
# Already in E.164 format
if clean_number.startswith("+"):
return clean_number
# Get dialing code for the country
if country_code:
dialing_code = get_country_code(country_code)
if dialing_code:
# Remove leading 0 if present (common in many countries)
if clean_number.startswith("0"):
clean_number = clean_number[1:]
# Add country code if not already present
if not clean_number.startswith(dialing_code):
return f"+{dialing_code}{clean_number}"
else:
return f"+{clean_number}"
# Fallback: try to guess common formats
if clean_number.startswith("0") and len(clean_number) == 11:
# Without country context, prefer India for now
return f"+91{clean_number[1:]}"
elif len(clean_number) == 10:
# Without context, this is ambiguous - return as-is with + prefix
return f"+{clean_number}"
elif not clean_number.startswith("+"):
# Add + prefix if missing
return f"+{clean_number}"
return clean_number
def normalize_webhook_data(provider_class, webhook_data):
"""Normalize webhook data using the provider's parse method"""
return provider_class.parse_inbound_webhook(webhook_data)
def generic_hangup_response():
"""Return a generic hangup response for unknown/error cases"""
return HTMLResponse(
content="<Response><Hangup/></Response>", media_type="application/xml"
)
async def parse_webhook_request(request: Request) -> tuple[dict, str]:
"""Parse webhook request data from either JSON or form"""
try:
# Try JSON first
webhook_data = await request.json()
data_source = "JSON"
except Exception:
try:
# Fallback to form data
form_data = await request.form()
webhook_data = dict(form_data)
data_source = "FORM"
except Exception as e:
logger.error(f"Failed to parse webhook data: {e}")
raise ValueError("Unable to parse webhook data")
return webhook_data, data_source
def get_country_code(country_iso: str) -> str:
"""
Get the international dialing code for a country.
Args:
country_iso: ISO 3166-1 alpha-2 country code (e.g., "US", "IN", "GB")
Returns:
International dialing code (e.g., "1", "91", "44") or empty string if not found
"""
if not country_iso:
return ""
return COUNTRY_CODES.get(country_iso.upper(), "")
def get_countries_for_code(dialing_code: str) -> list[str]:
"""
Get all countries that use a specific dialing code.
Args:
dialing_code: International dialing code (e.g., "1", "91")
Returns:
List of ISO country codes that use this dialing code
"""
if not dialing_code:
return []
return [country for country, code in COUNTRY_CODES.items() if code == dialing_code]

View file

@ -7,7 +7,7 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"fix-lint": "npx eslint --fix . --ignore-pattern '.next/*' --ignore-pattern 'node_modules/*'",
"fix-lint": "npx eslint --fix . --ignore-pattern '.next/*' --ignore-pattern 'node_modules/*' --ignore-pattern 'next-env.d.ts'",
"generate-client": "openapi-ts"
},
"dependencies": {

View file

@ -216,6 +216,7 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
<TableHead className="font-semibold">ID</TableHead>
<TableHead className="font-semibold">Status</TableHead>
<TableHead className="font-semibold">Created At</TableHead>
<TableHead className="font-semibold">Call Type</TableHead>
<TableHead className="font-semibold">Duration</TableHead>
<TableHead className="font-semibold">Disposition</TableHead>
<TableHead className="font-semibold">Dograh Token</TableHead>
@ -236,6 +237,11 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
</Badge>
</TableCell>
<TableCell className="text-sm">{formatDate(run.created_at)}</TableCell>
<TableCell>
<Badge variant={run.call_type === 'inbound' ? "secondary" : "default"}>
{run.call_type === 'inbound' ? 'Inbound' : 'Outbound'}
</Badge>
</TableCell>
<TableCell className="text-sm">
{typeof run.cost_info?.call_duration_seconds === 'number'
? `${run.cost_info.call_duration_seconds.toFixed(1)}s`

View file

@ -31,7 +31,7 @@ export default function CreateWorkflowPage() {
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [workflowId, setWorkflowId] = useState<string | null>(null);
const [callType, setCallType] = useState<'INBOUND' | 'OUTBOUND'>('INBOUND');
const [callType, setCallType] = useState<'inbound' | 'outbound'>('inbound');
const [useCase, setUseCase] = useState('');
const [activityDescription, setActivityDescription] = useState('');
@ -128,15 +128,15 @@ export default function CreateWorkflowPage() {
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="call-type">Call Type</Label>
<Select value={callType} onValueChange={(value) => setCallType(value as 'INBOUND' | 'OUTBOUND')}>
<Select value={callType} onValueChange={(value) => setCallType(value as 'inbound' | 'outbound')}>
<SelectTrigger id="call-type">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="INBOUND">
<SelectItem value="inbound">
Inbound (Users call AI)
</SelectItem>
<SelectItem value="OUTBOUND">
<SelectItem value="outbound">
Outbound (AI calls users)
</SelectItem>
</SelectContent>

File diff suppressed because one or more lines are too long

View file

@ -41,6 +41,8 @@ export type AuthUserResponse = {
is_superuser: boolean;
};
export type CallType = 'inbound' | 'outbound';
export type CampaignProgressResponse = {
campaign_id: number;
state: string;
@ -210,7 +212,7 @@ export type CreateWorkflowRunResponse = {
};
export type CreateWorkflowTemplateRequest = {
call_type: 'INBOUND' | 'OUTBOUND';
call_type: 'inbound' | 'outbound';
use_case: string;
activity_description: string;
};
@ -933,6 +935,7 @@ export type WorkflowRunResponseSchema = {
gathered_context?: {
[key: string]: unknown;
} | null;
call_type: CallType;
};
export type WorkflowRunUsageResponse = {
@ -986,6 +989,7 @@ export type InitiateCallApiV1TelephonyInitiateCallPostData = {
body: InitiateCallRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -1075,6 +1079,10 @@ export type HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostRespons
export type HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostData = {
body?: never;
headers?: {
'x-vobiz-signature'?: string | null;
'x-vobiz-timestamp'?: string | null;
};
path: {
workflow_run_id: number;
};
@ -1104,6 +1112,10 @@ export type HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRu
export type HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostData = {
body?: never;
headers?: {
'x-vobiz-signature'?: string | null;
'x-vobiz-timestamp'?: string | null;
};
path: {
workflow_run_id: number;
};
@ -1160,10 +1172,99 @@ export type HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWo
200: unknown;
};
export type HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostData = {
body?: never;
headers?: {
'x-vobiz-signature'?: string | null;
'x-vobiz-timestamp'?: string | null;
};
path: {
workflow_id: number;
};
query?: never;
url: '/api/v1/telephony/vobiz/hangup-callback/workflow/{workflow_id}';
};
export type HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostError = HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostErrors[keyof HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostErrors];
export type HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostData = {
body?: never;
headers?: {
'x-twilio-signature'?: string | null;
'x-vobiz-signature'?: string | null;
'x-vobiz-timestamp'?: string | null;
};
path: {
workflow_id: number;
};
query?: never;
url: '/api/v1/telephony/inbound/{workflow_id}';
};
export type HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostError = HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostErrors[keyof HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostErrors];
export type HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type HandleInboundFallbackApiV1TelephonyInboundFallbackPostData = {
body?: never;
path?: never;
query?: never;
url: '/api/v1/telephony/inbound/fallback';
};
export type HandleInboundFallbackApiV1TelephonyInboundFallbackPostErrors = {
/**
* Not found
*/
404: unknown;
};
export type HandleInboundFallbackApiV1TelephonyInboundFallbackPostResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type OfferApiV1PipecatRtcOfferPostData = {
body: RtcOfferRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -1194,6 +1295,7 @@ export type ImpersonateApiV1SuperuserImpersonatePostData = {
body: ImpersonateRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -1226,6 +1328,7 @@ export type GetWorkflowRunsApiV1SuperuserWorkflowRunsGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: {
@ -1271,6 +1374,7 @@ export type SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostData = {
body: AdminCommentRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
run_id: number;
@ -1305,6 +1409,7 @@ export type ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
workflow_id: number;
@ -1339,6 +1444,7 @@ export type CreateWorkflowApiV1WorkflowCreateDefinitionPostData = {
body: CreateWorkflowRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -1371,6 +1477,7 @@ export type CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostData = {
body: CreateWorkflowTemplateRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -1403,6 +1510,7 @@ export type GetWorkflowsApiV1WorkflowFetchGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: {
@ -1440,6 +1548,7 @@ export type GetWorkflowApiV1WorkflowFetchWorkflowIdGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
workflow_id: number;
@ -1474,6 +1583,7 @@ export type GetWorkflowsSummaryApiV1WorkflowSummaryGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -1506,6 +1616,7 @@ export type UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutData = {
body: UpdateWorkflowStatusRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
workflow_id: number;
@ -1540,6 +1651,7 @@ export type UpdateWorkflowApiV1WorkflowWorkflowIdPutData = {
body: UpdateWorkflowRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
workflow_id: number;
@ -1574,6 +1686,7 @@ export type GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
workflow_id: number;
@ -1615,6 +1728,7 @@ export type CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostData = {
body: CreateWorkflowRunRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
workflow_id: number;
@ -1649,6 +1763,7 @@ export type GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
workflow_id: number;
@ -1707,6 +1822,7 @@ export type DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostData = {
body: DuplicateTemplateRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -1762,6 +1878,7 @@ export type GetAuthUserApiV1UserAuthUserGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -1794,6 +1911,7 @@ export type GetUserConfigurationsApiV1UserConfigurationsUserGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -1826,6 +1944,7 @@ export type UpdateUserConfigurationsApiV1UserConfigurationsUserPutData = {
body: UserConfigurationRequestResponseSchema;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -1858,6 +1977,7 @@ export type ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetData
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: {
@ -1892,6 +2012,7 @@ export type GetApiKeysApiV1UserApiKeysGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: {
@ -1926,6 +2047,7 @@ export type CreateApiKeyApiV1UserApiKeysPostData = {
body: CreateApiKeyRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -1958,6 +2080,7 @@ export type ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
api_key_id: number;
@ -1994,6 +2117,7 @@ export type ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
api_key_id: number;
@ -2030,6 +2154,7 @@ export type GetVoicesApiV1UserConfigurationsVoicesProviderGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
provider: 'elevenlabs' | 'deepgram' | 'sarvam' | 'cartesia' | 'dograh';
@ -2064,6 +2189,7 @@ export type CreateCampaignApiV1CampaignCreatePostData = {
body: CreateCampaignRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -2096,6 +2222,7 @@ export type GetCampaignsApiV1CampaignGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -2128,6 +2255,7 @@ export type GetCampaignApiV1CampaignCampaignIdGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
campaign_id: number;
@ -2162,6 +2290,7 @@ export type StartCampaignApiV1CampaignCampaignIdStartPostData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
campaign_id: number;
@ -2196,6 +2325,7 @@ export type PauseCampaignApiV1CampaignCampaignIdPausePostData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
campaign_id: number;
@ -2230,6 +2360,7 @@ export type GetCampaignRunsApiV1CampaignCampaignIdRunsGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
campaign_id: number;
@ -2264,6 +2395,7 @@ export type ResumeCampaignApiV1CampaignCampaignIdResumePostData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
campaign_id: number;
@ -2298,6 +2430,7 @@ export type GetCampaignProgressApiV1CampaignCampaignIdProgressGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
campaign_id: number;
@ -2332,6 +2465,7 @@ export type GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrl
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
campaign_id: number;
@ -2366,6 +2500,7 @@ export type ListCredentialsApiV1CredentialsGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -2398,6 +2533,7 @@ export type CreateCredentialApiV1CredentialsPostData = {
body: CreateCredentialRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -2430,6 +2566,7 @@ export type DeleteCredentialApiV1CredentialsCredentialUuidDeleteData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
credential_uuid: string;
@ -2466,6 +2603,7 @@ export type GetCredentialApiV1CredentialsCredentialUuidGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
credential_uuid: string;
@ -2500,6 +2638,7 @@ export type UpdateCredentialApiV1CredentialsCredentialUuidPutData = {
body: UpdateCredentialRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
credential_uuid: string;
@ -2534,6 +2673,7 @@ export type ListToolsApiV1ToolsGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: {
@ -2569,6 +2709,7 @@ export type CreateToolApiV1ToolsPostData = {
body: CreateToolRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -2601,6 +2742,7 @@ export type DeleteToolApiV1ToolsToolUuidDeleteData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
tool_uuid: string;
@ -2637,6 +2779,7 @@ export type GetToolApiV1ToolsToolUuidGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
tool_uuid: string;
@ -2671,6 +2814,7 @@ export type UpdateToolApiV1ToolsToolUuidPutData = {
body: UpdateToolRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
tool_uuid: string;
@ -2705,6 +2849,7 @@ export type GetIntegrationsApiV1IntegrationGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -2737,6 +2882,7 @@ export type CreateSessionApiV1IntegrationSessionPostData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -2769,6 +2915,7 @@ export type UpdateIntegrationApiV1IntegrationIntegrationIdPutData = {
body: UpdateIntegrationRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
integration_id: number;
@ -2803,6 +2950,7 @@ export type GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGet
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
integration_id: number;
@ -2837,6 +2985,7 @@ export type GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetData =
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -2869,6 +3018,7 @@ export type SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostData
body: TwilioConfigurationRequest | VonageConfigurationRequest | VobizConfigurationRequest | CloudonixConfigurationRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -2899,6 +3049,7 @@ export type GetSignedUrlApiV1S3SignedUrlGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query: {
@ -2938,6 +3089,7 @@ export type GetFileMetadataApiV1S3FileMetadataGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query: {
@ -2975,6 +3127,7 @@ export type GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostData = {
body: PresignedUploadUrlRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -3007,6 +3160,7 @@ export type GetServiceKeysApiV1UserServiceKeysGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: {
@ -3041,6 +3195,7 @@ export type CreateServiceKeyApiV1UserServiceKeysPostData = {
body: CreateServiceKeyRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -3073,6 +3228,7 @@ export type ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
service_key_id: string;
@ -3105,6 +3261,7 @@ export type ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutDat
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
service_key_id: string;
@ -3137,6 +3294,7 @@ export type ListTestSessionsApiV1LooptalkTestSessionsGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: {
@ -3174,6 +3332,7 @@ export type CreateTestSessionApiV1LooptalkTestSessionsPostData = {
body: CreateTestSessionRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -3206,6 +3365,7 @@ export type GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
test_session_id: number;
@ -3240,6 +3400,7 @@ export type StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostData
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
test_session_id: number;
@ -3272,6 +3433,7 @@ export type StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostData =
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
test_session_id: number;
@ -3304,6 +3466,7 @@ export type GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConv
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
test_session_id: number;
@ -3336,6 +3499,7 @@ export type CreateLoadTestApiV1LooptalkLoadTestsPostData = {
body: CreateLoadTestRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -3370,6 +3534,7 @@ export type GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetData =
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
load_test_group_id: string;
@ -3404,6 +3569,7 @@ export type GetActiveTestsApiV1LooptalkActiveTestsGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -3434,6 +3600,7 @@ export type GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -3466,6 +3633,7 @@ export type GetUsageHistoryApiV1OrganizationsUsageRunsGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: {
@ -3513,6 +3681,7 @@ export type GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetData =
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: {
@ -3550,6 +3719,7 @@ export type GetDailyReportApiV1OrganizationsReportsDailyGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query: {
@ -3595,6 +3765,7 @@ export type GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
@ -3627,6 +3798,7 @@ export type GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query: {
@ -3816,6 +3988,7 @@ export type DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
workflow_id: number;
@ -3852,6 +4025,7 @@ export type GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
workflow_id: number;
@ -3886,6 +4060,7 @@ export type CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostData =
body: EmbedTokenRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
workflow_id: number;