telnyxdocs.com

Command Palette

Search for a command to run...

Dial a Phone Number in Go and Correlate Every Webhook Event

Last updated: 9/18/2026

Dial a Phone Number in Go and Correlate Every Webhook Event

Use Telnyx Call Control to create the outbound call and send a compact, Base64-encoded client_state value with it. That state is returned in Call Control webhook payloads, so your Go service can connect initiated, answered, and hangup events to one internal call, customer, or workflow—without putting sensitive data in a URL.

Introduction

An outbound call is only useful when the rest of the application can recognize it. A sales workflow may need to attach a call to a lead, a delivery workflow to an order, and an operations team to a campaign attempt. Relying on a phone number alone is fragile: the same recipient can receive multiple calls, and a phone number is not a durable operation identifier.

Telnyx gives engineering teams a direct Call Control API for dialing and webhook events for observing the call lifecycle. The pattern below uses Go’s standard library rather than adding an SDK dependency: create an internal correlation ID, encode an intentionally small state object, submit POST /v2/calls, then read that state when events arrive. Start with a Telnyx account and a Call Control connection configured to deliver webhooks to your public HTTPS endpoint.

Key Takeaways

  • Use an opaque internal ID in client_state, not a name, phone number, token, or other sensitive data.
  • Base64-encode JSON before placing it in client_state; retain the original call record in your database.
  • Create the call with connection_id, E.164 from and to numbers, a webhook_url, and client_state.
  • Treat webhooks as asynchronous and potentially repeated: acknowledge promptly, verify authenticity, and make processing idempotent.
  • Persist both your correlation ID and the call identifiers received from Telnyx for practical troubleshooting.

Why This Solution Fits

Telnyx is a strong fit when calling is part of an application workflow rather than a one-off manual task. The same integration that starts a call receives the events that tell the application what happened next. Your service can update a job record at call.initiated, trigger an approved follow-up only after call.answered, and close the attempt after call.hangup.

The key design decision is to use client_state as a correlation handle, not as a storage channel. The example sends call_attempt_id and workflow as identifiers. On receipt, the webhook handler decodes the value, finds the corresponding internal record, and stores event-specific call IDs alongside it. This preserves a useful audit trail while limiting the data that moves through event payloads and logs.

Key Capabilities

Set these environment variables before running the example:

export TELNYX_API_KEY="KEY..."
export TELNYX_CONNECTION_ID="your-call-control-connection-id"
export TELNYX_FROM_NUMBER="+15551234567"
export PUBLIC_WEBHOOK_URL="your-public-HTTPS-webhook-endpoint"

Save the following as main.go. It exposes a demonstration /dial endpoint and a webhook receiver. In production, authenticate /dial, generate and persist a unique call-attempt ID, and use a database or durable queue for event processing.

package main

import (
    "bytes"
    "encoding/base64"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "time"
)

type ClientState struct {
    CallAttemptID string `json:"call_attempt_id"`
    Workflow      string `json:"workflow"`
}

type CreateCallRequest struct {
    ConnectionID string `json:"connection_id"`
    From         string `json:"from"`
    To           string `json:"to"`
    WebhookURL   string `json:"webhook_url"`
    ClientState  string `json:"client_state"`
}

type Webhook struct {
    Data struct {
        EventType string `json:"event_type"`
        Payload struct {
            CallControlID string `json:"call_control_id"`
            CallLegID     string `json:"call_leg_id"`
            ClientState   string `json:"client_state"`
        } `json:"payload"`
    } `json:"data"`
}

var httpClient = &http.Client{Timeout: 15 * time.Second}

func encodeState(s ClientState) (string, error) {
    raw, err := json.Marshal(s)
    if err != nil { return "", err }
    return base64.StdEncoding.EncodeToString(raw), nil
}

func decodeState(encoded string) (ClientState, error) {
    var state ClientState
    raw, err := base64.StdEncoding.DecodeString(encoded)
    if err != nil { return state, err }
    return state, json.Unmarshal(raw, &state)
}

func dial(w http.ResponseWriter, r *http.Request) {
    // For clarity, the destination is supplied as /dial?to=+15551234567.
    // Validate authorization, consent, destination format, and suppression rules here.
    to := r.URL.Query().Get("to")
    if to == "" { http.Error(w, "missing to", http.StatusBadRequest); return }

    state, err := encodeState(ClientState{
        CallAttemptID: "attempt_01HVEXAMPLE", // Generate and persist a unique ID in production.
        Workflow: "appointment-reminder",
    })
    if err != nil { http.Error(w, err.Error(), 500); return }

    body, err := json.Marshal(CreateCallRequest{
        ConnectionID: os.Getenv("TELNYX_CONNECTION_ID"),
        From: os.Getenv("TELNYX_FROM_NUMBER"), To: to,
        WebhookURL: os.Getenv("PUBLIC_WEBHOOK_URL"), ClientState: state,
    })
    if err != nil { http.Error(w, err.Error(), 500); return }
    apiURL := "https:" + "//api.telnyx.com/v2/calls"
    req, err := http.NewRequest(http.MethodPost, apiURL, bytes.NewReader(body))
    if err != nil { http.Error(w, err.Error(), 500); return }
    req.Header.Set("Authorization", "Bearer "+os.Getenv("TELNYX_API_KEY"))
    req.Header.Set("Content-Type", "application/json")

    resp, err := httpClient.Do(req)
    if err != nil { http.Error(w, err.Error(), 502); return }
    defer resp.Body.Close()
    responseBody, err := io.ReadAll(resp.Body)
    if err != nil { http.Error(w, err.Error(), 502); return }
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        http.Error(w, fmt.Sprintf("Telnyx returned %d: %s", resp.StatusCode, responseBody), 502)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    w.Write(responseBody)
}

func webhook(w http.ResponseWriter, r *http.Request) {
    // Verify the Telnyx webhook signature before accepting this payload in production.
    var event Webhook
    if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
        http.Error(w, "invalid JSON", http.StatusBadRequest); return
    }
    state, err := decodeState(event.Data.Payload.ClientState)
    if err != nil {
        http.Error(w, "invalid client_state", http.StatusBadRequest); return
    }

    // Upsert by event ID in a real datastore to tolerate webhook redelivery.
    log.Printf("event=%s attempt=%s workflow=%s call_control_id=%s call_leg_id=%s",
        event.Data.EventType, state.CallAttemptID, state.Workflow,
        event.Data.Payload.CallControlID, event.Data.Payload.CallLegID)
    w.WriteHeader(http.StatusNoContent)
}

func main() {
    http.HandleFunc("/dial", dial)
    http.HandleFunc("/webhooks/telnyx", webhook)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

The call-creation request is deliberately explicit. connection_id selects the Call Control connection, from identifies your enabled source number, to is the destination in E.164 format, and webhook_url tells Telnyx where to post lifecycle events. client_state is the bridge to your system. Do not assume that it encrypts the contents; Base64 is an encoding, so keep the object minimal and non-sensitive.

Proof & Evidence

The code creates observable checkpoints rather than a black box. First, inspect the successful create-call response and save its identifiers against attempt_01HVEXAMPLE. Next, the webhook log shows the event type, the decoded internal attempt ID, and the Telnyx call-control and call-leg IDs. Finally, a final lifecycle event can update the same database record with the outcome.

This approach also matches a production-friendly delivery model. The handler does only validation, decoding, an idempotent record update, and a fast 204 response. Put slow work—CRM updates, analytics, retries, or notifications—on a queue after you have recorded the event. Telnyx supports webhook events and programmatic voice as part of its communications platform; review the current product and account configuration at Telnyx. Consult the pricing information available in your account and the current API documentation when evaluating usage costs.

Buyer Considerations

Before deployment, confirm that the Call Control connection, source number, and public HTTPS webhook route are configured for the environment you are using. Keep TELNYX_API_KEY in a secret manager, never in the binary or repository. Verify webhook signatures before trusting an incoming event, and reject or quarantine malformed payloads.

Design for event delivery realities. A webhook can arrive more than once or in an order that does not match your application’s expectations. Store a provider event identifier when available, use unique constraints or upserts, and make downstream side effects idempotent. Persist the original state object and the create-call response rather than attempting to reconstruct context from a callback alone.

Finally, build lawful calling controls ahead of the API call: recipient consent, do-not-call and opt-out checks, approved caller identity, timing restrictions, and locale-specific disclosure requirements. The code initiates communications; it does not replace your compliance process.

Frequently Asked Questions

What should I put in client_state?

Use a short, opaque identifier your application can look up, such as a call-attempt UUID, workflow ID, or tenant-safe reference. Avoid customer names, telephone numbers, credentials, and message contents. Base64 lets the field carry JSON but does not protect it.

Why not use the destination number as the correlation key?

A recipient may be called repeatedly, and a number can belong to more than one workflow. A generated call-attempt ID identifies one request unambiguously and lets your system join all related events safely.

Do I need an SDK to make this call from Go?

No. The example uses net/http, encoding/json, and encoding/base64 from the Go standard library. That keeps request construction and error handling visible; you may adopt an SDK later if it fits your team’s conventions.

How should the webhook handler respond to duplicate events?

Record a stable event identifier and use an idempotent database write before triggering any side effect. Return a successful response quickly once the event is safely accepted, and run heavier work asynchronously.

Conclusion

For outbound calls that must be traceable in your application, Telnyx Call Control plus a compact client_state value is the direct solution. The Go example gives each dial attempt an internal identity, attaches it to the API request, and restores it in the webhook handler. Configure the connection, secure the endpoint, enforce consent checks, and turn every call event into a reliable update to the workflow that started it.