The Problem

Imagine you're building a voice platform for restaurant chains. A customer calls a local number, hears an IVR menu, and either gets routed to an AI agent for ordering or a human agent for support. The platform serves hundreds of restaurant locations, each with their own phone numbers, IVR menus, queues, and agents.

You can't use a simple Twilio-style "webhook per call" approach here. At 200+ concurrent calls, the latency between SIP negotiation, media handling, IVR playback, queue lookup, and agent delivery must stay under two seconds end-to-end. And every tenant's call flow is different.

The typical evolution

Most teams start by building their call center on a provider's full managed stack — call control APIs, managed WebRTC, hosted IVR. It ships fast. But at scale, the per-minute surcharges for call control, WebRTC, and other managed features compound quickly — $5K–$6K/month on top of base trunk costs at thousands of calls per day.

The architecture this post covers is the next step: keep only the provider's basic SIP trunk services and own the routing, media, and WebRTC delivery yourself. Kamailio replaces managed call routing. FreeSWITCH replaces managed IVR, recording, and conferencing. SIP.js replaces managed WebRTC. The result: a 43% reduction in platform costs, with more control, lower latency, and no per-minute feature surcharges.

This post walks through how I'd architect that system — the SIP layer, media server, event-driven call flow, and WebRTC agent delivery. No proprietary code, just the design decisions and trade-offs.

Architecture Overview

The system has five layers. Each is independently scalable and replaceable:

Edge
PSTNInbound calls
SIP Trunk ProviderTelnyx / Vonage / Twilio
SIP Routing
SIP ProxyKamailio — route by DID + tenant
Media
Media ServerFreeSWITCH — IVR, DTMF, conferencing, recording
Object StorageS3 — IVR audio, call recordings
Application
VoIP ServiceESL bridge — call control API
Call APIRouting, state, CDRs
Queue ServiceAgent assignment, ACD logic
RedisCall events pub/sub
Client
Agent BrowserSIP.js WebRTC — answer, transfer, hold
Supervisor DashboardLive queue, listen, barge

Fig 1 — Five-layer architecture. Each layer is independently scalable.

Layer 1: SIP Routing — Why Kamailio

When a customer dials a restaurant's number, the call arrives as a SIP INVITE on your trunk provider. The first decision: how do you route it to the right tenant?

The naive approach is to let the trunk provider's webhook tell your API about the call, then your API tells the media server what to do. This adds 200-400ms of HTTP round-trip before the call even starts ringing. At scale, that latency compounds.

A better approach: put a SIP proxy in front of your media server. The proxy inspects the incoming INVITE, looks up the DID (the dialed number) in a fast lookup (in-memory hash or Redis), maps it to a tenant, and forwards the SIP directly to the correct media server — all at the SIP layer, before any HTTP touches the call.

OptionLatencyMulti-tenantComplexity
Direct to media server Lowest Hard — one config per tenant Low
Trunk webhook → API → media +200-400ms Easy — API handles routing Medium
SIP proxy (Kamailio) +2-5ms Native — DID lookup in SIP layer High initial, low ongoing

Kamailio wins here because it handles SIP at the protocol level. It can inspect headers, rewrite URIs, do tenant lookups against a database or Redis, and forward — all in under 5ms. It also handles SIP-level load balancing, failover, and topology hiding (your media server IPs never leak to the trunk provider).

Design decision

Kamailio's learning curve is steep (its config language is its own DSL), but the payoff is sub-5ms multi-tenant routing at the SIP layer. For a single-tenant system, you can skip it. For 100+ tenants with different DIDs, it's worth the investment.

The cost argument for owning the SIP layer

There's a financial reason to run your own SIP proxy, not just a latency one. Providers like Telnyx charge per-minute surcharges for managed services — call control, WebRTC, programmable IVR. When you're on their full managed stack, those fees are layered on top of the base SIP trunk rate.

By running Kamailio for routing, FreeSWITCH for media/IVR/recording, and SIP.js for WebRTC delivery on your own infrastructure, you replace those managed services entirely. You keep only the provider's limited SIP trunk services. At production scale, this saves $5K–$6K/month — a 43% reduction in platform costs compared to the fully-managed approach.

The trade-off: you take on the operational complexity of running Kamailio and FreeSWITCH in exchange for eliminating ~$60K–$70K/year in managed service fees. For a startup optimizing burn, that's a meaningful number.

Layer 2: Media Server — FreeSWITCH and ESL

Once the SIP proxy routes the call, the media server takes over. This is where the audio lives: IVR prompts, DTMF collection, conferencing, recording, and agent bridging.

The two serious open-source options are Asterisk and FreeSWITCH. For a multi-tenant contact center, FreeSWITCH has two decisive advantages:

  • ESL (Event Socket Library) — FreeSWITCH exposes a TCP socket that lets an external application control calls programmatically. Your Node.js service connects to ESL, receives events like CHANNEL_CREATE, and issues commands like originate, bridge, playback, conference. This means your call flow logic lives in your application code, not in a dialplan XML file.
  • Concurrency model — FreeSWITCH handles hundreds of concurrent calls more gracefully than Asterisk. Its internal architecture is thread-per-call rather than Asterisk's single-reactor model, which matters at 200+ concurrent sessions.

IVR Flow via ESL

When a call hits FreeSWITCH, your VoIP service receives a CHANNEL_CREATE event over ESL. It publishes a CALL_INITIATED event to Redis, which the Call API picks up. The Call API checks the tenant config: does this DID require an IVR? If yes, it tells the VoIP service to play cached audio from S3 and collect DTMF. If no, it routes directly to the queue.

The key insight: IVR audio is pre-cached in S3, not generated on the fly. Each tenant uploads their prompts ("Press 1 for ordering, press 2 for support"), which get cached on the media server's local disk on first use. Subsequent calls play from disk — zero latency.

Layer 3: Event-Driven Call Flow

Calls are stateful, long-lived processes. HTTP request-response is a poor fit for modeling them. Instead, the system uses Redis pub/sub as an event bus between services:

  • CALL_INITIATED — VoIP service → Call API. A new call arrived.
  • IVR_REQUIRED — Call API → VoIP service. Play IVR and collect input.
  • AGENT_ASSIGNED — Queue service → VoIP service. Bridge the caller to this agent's SIP URI.
  • CALL_ENDED — VoIP service → Call API. Write CDR, stop recording.

Why Redis pub/sub over HTTP callbacks?

HTTP callbacks require the receiving service to be up at the exact moment the event fires, and they add TCP handshake latency on every event. Redis pub/sub delivers events in under 1ms to any subscribed service, and if you need durability, you can swap in Redis Streams with consumer groups for at-least-once delivery.

State Management

Each active call gets a Redis hash keyed by call:{callId}. It stores the current state (ringing, ivr, queued, connected, ended), the tenant ID, the assigned agent, conference room name, and recording path. Every service reads and writes to this shared state — no service owns the full call lifecycle alone.

This pattern lets you add new services (an analytics service, a real-time dashboard, an AI transcription service) by simply subscribing to the same Redis channel. Zero changes to existing services.

Layer 4: Queue and Agent Assignment

When a call is ready for an agent (either after IVR or immediately), the Call API sends a routing request to the Queue Service. This service owns:

  • Agent availability — which agents are online, idle, on a call, on break
  • Routing strategy — round-robin, longest-idle, skills-based, priority queues
  • Queue position — hold music, estimated wait time, position announcements

When an agent is assigned, the Queue Service returns their agentId and sipUri. The VoIP service uses ESL to originate a new SIP leg to the agent's WebRTC client and bridge both legs into a conference. The conference room is where recording happens — FreeSWITCH writes the mixed audio to S3.

Layer 5: WebRTC Agent Delivery

Agents don't use desk phones. They open a browser, log in, and receive calls via WebRTC. The agent's browser runs SIP.js, which registers as a SIP user agent against the FreeSWITCH instance.

When the VoIP service originates a call to the agent's SIP URI, FreeSWITCH sends a SIP INVITE to the registered WebRTC client. The agent's browser rings, they click answer, and WebRTC media flows directly between FreeSWITCH and the browser. No plugins, no softphone installs.

This also enables features that traditional phone-based contact centers can't do easily:

  • Screen pop — the call metadata (caller ID, tenant, IVR selections) arrives over a WebSocket before the call rings, so the agent sees customer context immediately
  • Supervisor listen/barge — a supervisor joins the FreeSWITCH conference in listen-only mode, or barges in as a third party
  • AI handoff — if an AI agent is handling the call and can't resolve it, the system transfers the conference to a human agent with full conversation context

The Full Inbound Call Sequence

Putting it all together, here's the complete flow from a customer dialing a number to hearing an agent's voice:

1. PSTN → Customer dials restaurant DID 2. SIP Trunk → Receives INVITE, forwards to your infrastructure 3. SIP Proxy → Looks up DID → resolves tenant → routes to media server (+2-5ms) 4. Media Server → CHANNEL_CREATE event fires over ESL 5. VoIP Service → Publishes CALL_INITIATED to Redis 6. Call API → Receives event, checks tenant config 7a. [If IVR required] Call API → Sends IVR_REQUIRED → VoIP Service → Media Server plays cached S3 audio, collects DTMF 7b. Call API → POST routing request to Queue Service 8. Queue Service → Finds available agent → returns agentId + sipUri 9. VoIP Service → ESL originate to agent's SIP URI 10. Media Server → SIP INVITE to agent's registered WebRTC client 11. Agent Browser → SIP.js rings → agent answers → WebRTC media flows 12. Media Server → Both legs joined in conference → recording streams to S3

Fig 2 — Complete inbound call sequence, 12 steps from PSTN to connected agent.

Steps 1-6 take under 500ms in production. The IVR step (7a) depends on the menu length. Agent assignment (7b-8) is near-instant if agents are available, or the caller enters a hold queue. The originate-to-answer leg (9-11) adds another 1-3 seconds depending on network conditions.

Scaling Considerations

Horizontal scaling of media servers

A single FreeSWITCH instance handles ~500 concurrent calls comfortably. Beyond that, you run multiple instances behind Kamailio, which load-balances using a dispatcher module. Each media server is stateless from the SIP proxy's perspective — call state lives in Redis, not on the media server.

Multi-region deployment

For a US-serving platform, you'd deploy media servers in US-East and US-West. Kamailio routes based on the trunk provider's closest PoP. Redis is replicated across regions. The application services (Call API, Queue Service, VoIP Service) run on ECS/Kubernetes with standard auto-scaling.

Failover

If a media server goes down mid-call, those calls are lost — there's no practical way to migrate live RTP streams. But new calls are automatically routed to healthy instances within seconds. Kamailio's dispatcher module detects failures via SIP OPTIONS pings and removes dead nodes from the routing table.

Lessons

  • Keep call control out of the media server's config. FreeSWITCH's XML dialplan is powerful but impossible to manage at scale. Use ESL to drive all logic from your application code, where you have proper version control, tests, and deployment pipelines.
  • Redis pub/sub is good enough for call events. Calls are ephemeral (average 3-5 minutes). You don't need Kafka-level durability. If an event is lost, the call fails — which is the same outcome as a service being down. Keep it simple.
  • Pre-cache everything. IVR audio, hold music, agent greeting prompts — cache them on the media server's local disk. Every network round-trip during a live call is latency the caller hears.
  • WebRTC is the right default for agents. Desk phones add hardware cost, SIP registration complexity, and NAT traversal headaches. Browser-based agents with SIP.js are easier to deploy, easier to update, and enable richer UI features. Running your own SIP.js registration against FreeSWITCH also avoids per-minute WebRTC fees from your trunk provider.

This architecture handles hundreds of concurrent calls across hundreds of tenants with sub-second routing. The key is keeping each layer focused: SIP proxy routes, media server handles audio, application services own business logic, and everything communicates through events rather than synchronous HTTP chains.

Want to discuss this architecture or have questions?
hello@aawezkhan.com · LinkedIn