Plivo Voice Agent Integration

Integrate Plivo telephony with AI voice agents. Cost-effective alternative to Twilio, WebSocket streaming, global phone numbers. Step-by-step setup guide with pricing comparison.

Plivo Integration

Plivo provides reliable telephony infrastructure with competitive pricing and global coverage, making it a strong alternative to Twilio.

Why Plivo?

Feature Plivo Twilio
Cost per minute ~$0.008 ~$0.013
Global coverage 190+ countries 180+ countries
WebSocket support Yes Yes
API simplicity Simple Complex
Support Good Excellent

Configuration

Environment Variables

PLIVO_AUTH_ID=your_auth_id
PLIVO_AUTH_TOKEN=your_auth_token
PLIVO_APP_ID=your_application_id

Plivo Application Setup

  1. Create a Plivo Application in the console
  2. Set the Answer URL to your webhook endpoint
  3. Enable XML (PHLO not required)

Answer URL Configuration

Answer URL: https://your-server.com/api/plivo/answer
Fallback URL: https://your-server.com/api/plivo/fallback
Hangup URL: https://your-server.com/api/plivo/hangup

Implementation

Webhook Handler

func handlePlivoAnswer(w http.ResponseWriter, r *http.Request) {
    // Parse incoming call parameters
    callUUID := r.FormValue("CallUUID")
    from := r.FormValue("From")
    to := r.FormValue("To")

    // Determine agent from called number
    agent := getAgentByPhoneNumber(to)

    // Return XML response with WebSocket stream
    response := plivoxml.ResponseElement{}
    response.AddStream(plivoxml.StreamElement{
        StreamURL:        fmt.Sprintf("wss://your-server.com/ws/plivo/%s", agent.ID),
        Bidirectional:    true,
        AudioTrack:       "inbound",
        ContentType:      "audio/x-l16;rate=8000",
    })

    xml, _ := response.ToXML()
    w.Header().Set("Content-Type", "application/xml")
    w.Write([]byte(xml))
}

XML Response Format

<?xml version="1.0" encoding="UTF-8"?>
<Response>
    <Stream bidirectional="true"
            audioTrack="inbound"
            contentType="audio/x-l16;rate=8000">
        wss://your-server.com/ws/plivo/agent-123
    </Stream>
</Response>

WebSocket Handler

type PlivoWSHandler struct {
    pipeline *Pipeline
    conn     *websocket.Conn
}

func (h *PlivoWSHandler) HandleConnection(w http.ResponseWriter, r *http.Request) {
    // Upgrade to WebSocket
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        return
    }
    h.conn = conn
    defer conn.Close()

    // Extract agent ID from path
    agentID := chi.URLParam(r, "agentID")

    // Initialize pipeline
    agent := getAgent(agentID)
    h.pipeline = NewPipeline(agent, h)

    // Start pipeline
    ctx, cancel := context.WithCancel(r.Context())
    defer cancel()

    go h.pipeline.Run(ctx)

    // Handle WebSocket messages
    h.handleMessages()
}

func (h *PlivoWSHandler) handleMessages() {
    for {
        msgType, msg, err := h.conn.ReadMessage()
        if err != nil {
            return
        }

        if msgType == websocket.BinaryMessage {
            // Audio data from caller
            h.processAudio(msg)
        } else if msgType == websocket.TextMessage {
            // Control message
            h.processControlMessage(msg)
        }
    }
}

Audio Processing

func (h *PlivoWSHandler) processAudio(audio []byte) {
    // Plivo sends L16 PCM at 8kHz
    samples := bytesToInt16LE(audio)

    // Send to pipeline
    h.pipeline.ProcessAudio(samples)
}

func (h *PlivoWSHandler) SendAudio(audio []byte) error {
    // Convert to L16 format
    l16Audio := int16ToBytes(audio)

    // Send as binary message
    return h.conn.WriteMessage(websocket.BinaryMessage, l16Audio)
}

func (h *PlivoWSHandler) processControlMessage(msg []byte) {
    var event PlivoEvent
    json.Unmarshal(msg, &event)

    switch event.Event {
    case "start":
        log.Printf("Stream started: %s", event.StreamID)
        h.streamID = event.StreamID

    case "stop":
        log.Printf("Stream stopped")
        h.pipeline.Stop()
    }
}

Audio Format

Parameter Plivo Specification
Sample Rate 8000 Hz
Encoding L16 (signed 16-bit little-endian PCM)
Channels Mono
Frame Size Variable (typically 20ms = 320 bytes)

L16 vs μ-law

Unlike Twilio which uses μ-law, Plivo uses L16 (Linear PCM):

// No conversion needed for L16
func processL16Audio(audio []byte) []int16 {
    samples := make([]int16, len(audio)/2)
    for i := 0; i < len(samples); i++ {
        samples[i] = int16(audio[i*2]) | int16(audio[i*2+1])<<8
    }
    return samples
}

// For output, convert back to bytes
func int16ToL16Bytes(samples []int16) []byte {
    audio := make([]byte, len(samples)*2)
    for i, s := range samples {
        audio[i*2] = byte(s)
        audio[i*2+1] = byte(s >> 8)
    }
    return audio
}

Outbound Calls

Initiate Call

func initiateOutboundCall(to, agentID string) (*PlivoCallResponse, error) {
    client := plivo.NewClient(authID, authToken, nil)

    response, err := client.Calls.Create(plivo.CallCreateParams{
        From:      plivoPhoneNumber,
        To:        to,
        AnswerURL: fmt.Sprintf("https://your-server.com/api/plivo/outbound/%s", agentID),
        AnswerMethod: "POST",
    })
    if err != nil {
        return nil, err
    }

    return response, nil
}

Outbound Answer Handler

func handlePlivoOutbound(w http.ResponseWriter, r *http.Request) {
    agentID := chi.URLParam(r, "agentID")
    callUUID := r.FormValue("CallUUID")

    // Store call details for WebSocket lookup
    storeCallDetails(callUUID, agentID)

    // Return WebSocket stream
    response := plivoxml.ResponseElement{}
    response.AddStream(plivoxml.StreamElement{
        StreamURL:     fmt.Sprintf("wss://your-server.com/ws/plivo/%s/%s", agentID, callUUID),
        Bidirectional: true,
        AudioTrack:    "inbound",
        ContentType:   "audio/x-l16;rate=8000",
    })

    xml, _ := response.ToXML()
    w.Header().Set("Content-Type", "application/xml")
    w.Write([]byte(xml))
}

Call Transfer

func transferCall(callUUID, transferTo string) error {
    client := plivo.NewClient(authID, authToken, nil)

    // Update call with transfer
    _, err := client.Calls.Update(callUUID, plivo.CallUpdateParams{
        LegsID: "aleg",
        AlegURL: fmt.Sprintf("https://your-server.com/api/plivo/transfer?to=%s", transferTo),
        AlegMethod: "POST",
    })

    return err
}

func handleTransfer(w http.ResponseWriter, r *http.Request) {
    to := r.URL.Query().Get("to")

    response := plivoxml.ResponseElement{}
    response.AddDial(plivoxml.DialElement{
        Children: []interface{}{
            plivoxml.NumberElement{Number: to},
        },
    })

    xml, _ := response.ToXML()
    w.Header().Set("Content-Type", "application/xml")
    w.Write([]byte(xml))
}

DTMF Handling

func (h *PlivoWSHandler) processControlMessage(msg []byte) {
    var event PlivoEvent
    json.Unmarshal(msg, &event)

    switch event.Event {
    case "dtmf":
        digit := event.Digit
        log.Printf("DTMF received: %s", digit)
        h.pipeline.HandleDTMF(digit)
    }
}

Error Handling

func handlePlivoFallback(w http.ResponseWriter, r *http.Request) {
    errorType := r.FormValue("ErrorType")
    log.Printf("Plivo fallback triggered: %s", errorType)

    // Return a simple response or transfer to backup
    response := plivoxml.ResponseElement{}
    response.AddSpeak(plivoxml.SpeakElement{
        Text: "We're experiencing technical difficulties. Please try again later.",
    })
    response.AddHangup(plivoxml.HangupElement{})

    xml, _ := response.ToXML()
    w.Header().Set("Content-Type", "application/xml")
    w.Write([]byte(xml))
}

Webhooks

Hangup Webhook

func handlePlivoHangup(w http.ResponseWriter, r *http.Request) {
    callUUID := r.FormValue("CallUUID")
    duration := r.FormValue("Duration")
    hangupCause := r.FormValue("HangupCause")

    log.Printf("Call ended: %s, duration: %s, cause: %s",
        callUUID, duration, hangupCause)

    // Clean up session
    cleanupSession(callUUID)

    // Store call record
    storeCallRecord(CallRecord{
        UUID:     callUUID,
        Duration: parseInt(duration),
        Cause:    hangupCause,
    })

    w.WriteHeader(http.StatusOK)
}

Cost Comparison

Route Plivo Twilio
US Local $0.008/min $0.013/min
US Toll-Free $0.018/min $0.022/min
India $0.003/min $0.0085/min
UK $0.009/min $0.012/min
Phone Numbers $0.80/mo $1.00/mo

Best Practices

1. Use Connection Pooling

var plivoClient = plivo.NewClient(authID, authToken, &plivo.ClientOptions{
    HTTPClient: &http.Client{
        Transport: &http.Transport{
            MaxIdleConns:        100,
            MaxIdleConnsPerHost: 100,
            IdleConnTimeout:     90 * time.Second,
        },
    },
})

2. Implement Retries

func callWithRetry(params plivo.CallCreateParams) (*plivo.CallCreateResponse, error) {
    maxRetries := 3
    backoff := 500 * time.Millisecond

    for i := 0; i < maxRetries; i++ {
        resp, err := plivoClient.Calls.Create(params)
        if err == nil {
            return resp, nil
        }

        if !isRetryable(err) {
            return nil, err
        }

        time.Sleep(backoff)
        backoff *= 2
    }

    return nil, fmt.Errorf("max retries exceeded")
}

3. Monitor Call Quality

func logCallMetrics(callUUID string) {
    // Get call details
    call, _ := plivoClient.Calls.Get(callUUID)

    metrics.RecordHistogram("plivo.call.duration", float64(call.Duration))
    metrics.RecordCounter("plivo.calls.total", 1)

    if call.HangupCause != "NORMAL_CLEARING" {
        metrics.RecordCounter("plivo.calls.failed", 1)
    }
}

Next Steps