Skip to content

Phone Number Routing: Multiple Assistants, One Webhook

When you have multiple phone numbers - each for a different purpose - you don't need separate webhook endpoints. Route them all to a single endpoint and dispatch based on the called number.

The Pattern

Every sipgate AI Flow event includes the phone number in the session object:

json
{
  "type": "session_start",
  "session": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "to_phone_number": "+4921234567890",
    "from_phone_number": "+4915112345678",
    "direction": "inbound"
  }
}

Use to_phone_number to determine which assistant or behavior to use.

Basic Routing

typescript
const ASSISTANTS = {
  '+4921234567890': {
    name: 'Sales',
    greeting: 'Hi! Thanks for calling our sales team. How can I help?',
    systemPrompt: 'You are a helpful sales assistant...',
  },
  '+4921234567891': {
    name: 'Support',
    greeting: 'Hello! You\'ve reached customer support. What can I help you with?',
    systemPrompt: 'You are a friendly support agent...',
  },
  '+4921234567892': {
    name: 'Booking',
    greeting: 'Welcome! I can help you book an appointment. When would you like to come in?',
    systemPrompt: 'You are an appointment booking assistant...',
  },
}

export async function POST(req: Request) {
  const event = await req.json()
  const phoneNumber = event.session.to_phone_number

  // Get assistant config for this number
  const assistant = ASSISTANTS[phoneNumber]

  if (!assistant) {
    // Unknown number - use fallback
    return speak(event.session.id, "Sorry, this number is not configured.")
  }

  switch (event.type) {
    case 'session_start':
      return speak(event.session.id, assistant.greeting)

    case 'user_speak':
      return handleUserSpeak(event, assistant)

    // ... other events
  }
}

Database-Driven Routing

For dynamic configuration, store the mapping in a database:

typescript
// Database schema
// phone_numbers: id, phone_number, assistant_id
// assistants: id, name, greeting, system_prompt, voice_provider, voice_id

async function getAssistantForNumber(phoneNumber: string) {
  const { data } = await supabase
    .from('phone_numbers')
    .select(`
      phone_number,
      assistants (
        id,
        name,
        greeting,
        system_prompt,
        voice_provider,
        voice_id
      )
    `)
    .eq('phone_number', phoneNumber)
    .single()

  return data?.assistants
}

export async function POST(req: Request) {
  const event = await req.json()
  const phoneNumber = event.session.to_phone_number

  const assistant = await getAssistantForNumber(phoneNumber)

  if (!assistant) {
    return speak(event.session.id, "This number is not currently in service.")
  }

  // Route to appropriate handler
  return handleEvent(event, assistant)
}

Normalizing Phone Numbers

Phone numbers can arrive in different formats. Normalize before lookup:

typescript
function normalizePhoneNumber(phone: string): string {
  // Remove spaces, dashes, parentheses
  let normalized = phone.replace(/[\s\-\(\)]/g, '')

  // Ensure + prefix
  if (!normalized.startsWith('+')) {
    normalized = '+' + normalized
  }

  return normalized
}

async function getAssistantForNumber(phoneNumber: string) {
  const normalized = normalizePhoneNumber(phoneNumber)

  const { data } = await supabase
    .from('phone_numbers')
    .select('*, assistants(*)')
    .eq('phone_number', normalized)
    .single()

  return data?.assistants
}

Multi-Language Routing

Route to different languages based on phone number:

typescript
const LANGUAGE_NUMBERS = {
  '+4921234567890': { language: 'de-DE', voice: 'de-DE-KatjaNeural' },
  '+4421234567890': { language: 'en-GB', voice: 'en-GB-SoniaNeural' },
  '+3321234567890': { language: 'fr-FR', voice: 'fr-FR-DeniseNeural' },
}

function getLanguageConfig(phoneNumber: string) {
  return LANGUAGE_NUMBERS[phoneNumber] || {
    language: 'en-US',
    voice: 'en-US-JennyNeural',
  }
}

export async function POST(req: Request) {
  const event = await req.json()
  const langConfig = getLanguageConfig(event.session.to_phone_number)

  // Use language-specific TTS
  return Response.json({
    type: 'speak',
    session_id: event.session.id,
    text: getGreeting(langConfig.language),
    tts: {
      provider: 'azure',
      language: langConfig.language,
      voice: langConfig.voice,
    },
  })
}

Routing by Caller Number

You can also route based on who's calling (from_phone_number):

typescript
async function handleSessionStart(event: SessionStartEvent) {
  const callerNumber = event.session.from_phone_number

  // Check if this is a known VIP customer
  const customer = await getCustomerByPhone(callerNumber)

  if (customer?.is_vip) {
    return speak("Welcome back! I see you're a VIP member. How can I assist you today?")
  }

  // Check if this is a repeat caller
  const previousCalls = await getRecentCalls(callerNumber)

  if (previousCalls.length > 0) {
    const lastTopic = previousCalls[0].topic
    return speak(`Hello again! Are you calling about ${lastTopic}, or something new?`)
  }

  // First-time caller
  return speak("Welcome! How can I help you today?")
}

Fallback Handling

Always handle unknown numbers gracefully:

typescript
async function getAssistantForNumber(phoneNumber: string) {
  const normalized = normalizePhoneNumber(phoneNumber)

  const { data } = await supabase
    .from('phone_numbers')
    .select('*, assistants(*)')
    .eq('phone_number', normalized)
    .single()

  if (!data?.assistants) {
    // Log for debugging
    console.warn(`No assistant configured for: ${normalized}`)

    // Return a default fallback assistant
    return {
      id: 'fallback',
      name: 'Fallback',
      greeting: "I'm sorry, but this number is not currently configured. Please try again later.",
      system_prompt: 'Politely explain that the service is unavailable.',
      voice_provider: 'azure',
      voice_id: 'en-US-JennyNeural',
    }
  }

  return data.assistants
}

Caching for Performance

If you're looking up the same numbers repeatedly, cache the results:

typescript
const assistantCache = new Map<string, { assistant: Assistant; cachedAt: number }>()
const CACHE_TTL_MS = 60000 // 1 minute

async function getAssistantForNumber(phoneNumber: string): Promise<Assistant> {
  const normalized = normalizePhoneNumber(phoneNumber)

  // Check cache
  const cached = assistantCache.get(normalized)
  if (cached && Date.now() - cached.cachedAt < CACHE_TTL_MS) {
    return cached.assistant
  }

  // Fetch from database
  const { data } = await supabase
    .from('phone_numbers')
    .select('*, assistants(*)')
    .eq('phone_number', normalized)
    .single()

  const assistant = data?.assistants || getFallbackAssistant()

  // Cache result
  assistantCache.set(normalized, { assistant, cachedAt: Date.now() })

  return assistant
}

Complete Example

typescript
// types.ts
interface Assistant {
  id: string
  name: string
  greeting: string
  system_prompt: string
  voice_provider: 'azure' | 'eleven_labs'
  voice_id: string
  language: string
}

// routing.ts
const assistantCache = new Map<string, Assistant>()

function normalizePhoneNumber(phone: string): string {
  let normalized = phone.replace(/[\s\-\(\)]/g, '')
  if (!normalized.startsWith('+')) normalized = '+' + normalized
  return normalized
}

async function getAssistant(phoneNumber: string): Promise<Assistant> {
  const normalized = normalizePhoneNumber(phoneNumber)

  // Check cache
  if (assistantCache.has(normalized)) {
    return assistantCache.get(normalized)!
  }

  // Fetch from database
  const { data } = await db
    .from('phone_numbers')
    .select('*, assistants(*)')
    .eq('phone_number', normalized)
    .single()

  const assistant = data?.assistants || {
    id: 'fallback',
    name: 'Fallback',
    greeting: 'This number is not configured.',
    system_prompt: 'Explain the service is unavailable.',
    voice_provider: 'azure',
    voice_id: 'en-US-JennyNeural',
    language: 'en-US',
  }

  assistantCache.set(normalized, assistant)
  return assistant
}

// webhook.ts
export async function POST(req: Request): Promise<Response> {
  const event = await req.json()
  const sessionId = event.session.id

  // Route to assistant based on called number
  const assistant = await getAssistant(event.session.to_phone_number)

  switch (event.type) {
    case 'session_start':
      console.log(`Call to ${assistant.name} assistant`)
      return speak(sessionId, assistant.greeting, assistant)

    case 'user_speak':
      const response = await generateLLMResponse(event.text, assistant)
      return speak(sessionId, response, assistant)

    case 'session_end':
      return new Response(null, { status: 204 })

    default:
      return new Response(null, { status: 204 })
  }
}

function speak(sessionId: string, text: string, assistant: Assistant): Response {
  const ttsConfig = assistant.voice_provider === 'azure'
    ? {
        provider: 'azure' as const,
        language: assistant.language,
        voice: assistant.voice_id,
      }
    : {
        provider: 'eleven_labs' as const,
        voice: assistant.voice_id,
      }

  return Response.json({
    type: 'speak',
    session_id: sessionId,
    text,
    tts: ttsConfig,
  })
}

Best Practices

  1. Normalize phone numbers - Handle different formats (+49, 0049, 049, etc.)

  2. Always have a fallback - Unknown numbers should get a polite message, not an error

  3. Cache lookups - Database queries on every event add latency

  4. Log unknown numbers - Helps you spot misconfiguration

  5. Use to_phone_number - That's the number they dialed (your number)

  6. Consider from_phone_number - For personalization based on caller