Skip to main content
Telnyx AI Assistants can call out to your own backend in several scenarios — resolving dynamic variables at the start of a conversation, executing webhook tool calls mid-conversation, and more. Whenever you need a backend for these callbacks, Telnyx Edge Compute is a natural fit: no server to manage, secrets injected at runtime, and deployment via a single CLI command. This guide walks through building a single Go function that handles both dynamic variables and webhook tool calls, using the demo app telnyx-ai-edge as the reference implementation.

What you’ll build

A support assistant for “Telnyx Logistics” that:
  • Greets callers by name (dynamic variables resolved from the caller’s phone number)
  • Has a lookup-order tool the assistant can call to retrieve order status, carrier, and estimated delivery
Both the dynamic variable lookup and the tool call hit one Edge Compute function at a single URL.

Prerequisites


Key concepts

Single function, two callbacks

Edge Compute routes all HTTP methods and paths under your function URL to your handler — path handling is up to your code (see Routes & Domains). The platform handles /health/liveness and /health/readiness probes automatically. In this guide, both the dynamic variables webhook and the webhook tool call point to the same function URL, so the handler dispatches on the request body shape rather than the URL path:
  • Dynamic variables webhook — Telnyx wraps the payload under data.event_type.
  • Webhook tool call — the body is the flat arguments object from the tool’s body_parameters schema (e.g. {"order_id": "ORD-10042"}).
You could also use separate paths (e.g. /dynamic-variables and /tool/lookup-order) if you prefer path-based routing — both approaches work. This guide uses body-shape dispatch to keep everything at a single URL.

Webhook signature verification

Telnyx signs every dynamic-variables webhook and webhook tool call with an Ed25519 key. The signature is in the telnyx-signature-ed25519 header, and the timestamp is in telnyx-timestamp. The signed message is "{timestamp}|{raw_body}". You must verify this signature to confirm the request is genuinely from Telnyx. Your org’s public key is available at:
GET https://api.telnyx.com/v2/public_key
Authorization: Bearer <TELNYX_API_KEY>
The response contains data.public (not data.public_key) — the base64-encoded Ed25519 public key.

Dynamic variables response format

The response must nest variables under a dynamic_variables key. A flat object (e.g. {"customer_name": "James"}) is silently ignored — variables will remain unresolved.
{
  "dynamic_variables": {
    "customer_name": "James Smith",
    "account_tier": "premium"
  }
}

Timeout

The default dynamic variables webhook timeout is 1,500 ms. Edge Compute functions may occasionally need more time on a cold start, so consider setting dynamic_variables_webhook_timeout_ms on the assistant to a higher value (up to 10,000 ms). A value of 8,000 ms is a reasonable choice for edge backends.

Step 1: Scaffold the function

telnyx-edge new-func -l go -n telnyx-ai-edge
cd telnyx-ai-edge
This creates a func.toml with the registered function ID and a Go handler scaffold.
The Go module must be named function (package function, entrypoint Handle(w, r)). Other module names fail to build: “malformed module path: missing dot in first path element.” Use go 1.24 in go.mod.

Step 2: Store the public key as a secret

Fetch your org’s public key and store it as an encrypted secret. The public key endpoint requires authentication — use your Telnyx API key:
# Get the public key (requires authentication)
PUBLIC_KEY=$(curl -s -H "Authorization: Bearer $TELNYX_API_KEY" \
  https://api.telnyx.com/v2/public_key | jq -r '.data.public')

# Store it as a secret (encrypted, org-scoped, injected as env var at runtime)
telnyx-edge secrets add TELNYX_PUBLIC_KEY "$PUBLIC_KEY"
The function reads this secret from os.Getenv("TELNYX_PUBLIC_KEY") at startup. Secrets are never visible in secrets list — only the name is shown.

Step 3: Write the handler

The handler does three things:
  1. Verifies the Telnyx Ed25519 signature on every request
  2. Detects whether the request is a dynamic-variables webhook or a tool call
  3. Returns the appropriate response
handler.go
package function

import (
	"crypto/ed25519"
	"encoding/base64"
	"encoding/json"
	"io"
	"log"
	"net/http"
	"os"
	"strconv"
	"time"
)

const maxSkew = 5 * time.Minute

var publicKey ed25519.PublicKey

func init() {
	raw := os.Getenv("TELNYX_PUBLIC_KEY")
	if raw == "" {
		log.Println("warning: TELNYX_PUBLIC_KEY is not set; all requests will be rejected")
		return
	}
	key, err := base64.StdEncoding.DecodeString(raw)
	if err != nil || len(key) != ed25519.PublicKeySize {
		log.Printf("warning: TELNYX_PUBLIC_KEY is invalid (len=%d, err=%v)", len(key), err)
		return
	}
	publicKey = ed25519.PublicKey(key)
}

func Handle(w http.ResponseWriter, r *http.Request) {
	// Health probes are handled by the platform — don't add custom health routes
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}

	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "cannot read body", http.StatusBadRequest)
		return
	}

	if !verifyTelnyxSignature(r.Header, body) {
		http.Error(w, "invalid signature", http.StatusForbidden)
		return
	}

	// Dispatch on body shape: DV webhook has "data.event_type",
	// tool call is a flat args object
	if isDynamicVariablesRequest(body) {
		handleDynamicVariables(w, body)
		return
	}
	handleLookupOrder(w, body)
}

func isDynamicVariablesRequest(body []byte) bool {
	var probe struct {
		Data *struct {
			EventType string `json:"event_type"`
		} `json:"data"`
	}
	if err := json.Unmarshal(body, &probe); err != nil {
		return false
	}
	return probe.Data != nil
}

func verifyTelnyxSignature(h http.Header, body []byte) bool {
	if publicKey == nil {
		return false
	}

	sig := h.Get("telnyx-signature-ed25519")
	ts := h.Get("telnyx-timestamp")
	if sig == "" || ts == "" {
		return false
	}

	t, err := strconv.ParseInt(ts, 10, 64)
	if err != nil {
		return false
	}
	age := time.Since(time.Unix(t, 0))
	if age < -maxSkew || age > maxSkew {
		return false
	}

	s, err := base64.StdEncoding.DecodeString(sig)
	if err != nil {
		return false
	}

	signed := append([]byte(ts+"|"), body...)
	return ed25519.Verify(publicKey, signed, s)
}

// --- Dynamic Variables ---

type dvRequest struct {
	Data struct {
		EventType string `json:"event_type"`
		Payload   struct {
			Channel       string `json:"telnyx_conversation_channel"`
			AgentTarget   string `json:"telnyx_agent_target"`
			EndUserTarget string `json:"telnyx_end_user_target"`
			CallControlID string `json:"call_control_id"`
			AssistantID   string `json:"assistant_id"`
		} `json:"payload"`
	} `json:"data"`
}

type dvResponse struct {
	DynamicVariables map[string]any `json:"dynamic_variables"`
}

func handleDynamicVariables(w http.ResponseWriter, body []byte) {
	var req dvRequest
	if err := json.Unmarshal(body, &req); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}

	caller := req.Data.Payload.EndUserTarget

	resp := dvResponse{
		DynamicVariables: map[string]any{
			"customer_name":  lookupCustomerName(caller),
			"account_tier":   "premium",
			"open_order_id":  "ORD-10042",
			"support_region": "US",
		},
	}

	writeJSON(w, resp)
}

func lookupCustomerName(caller string) string {
	known := map[string]string{
		"+13128675309": "James Smith",
		"+15551234567": "Rachel Thomas",
	}
	if name, ok := known[caller]; ok {
		return name
	}
	return "there"
}

// --- Webhook Tool: lookup-order ---

type toolRequest struct {
	OrderID string `json:"order_id"`
}

type toolResponse struct {
	OrderID        string `json:"order_id"`
	Status         string `json:"status"`
	EstimatedDeliv string `json:"estimated_delivery"`
	Carrier        string `json:"carrier"`
}

func handleLookupOrder(w http.ResponseWriter, body []byte) {
	var req toolRequest
	if err := json.Unmarshal(body, &req); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}

	resp := toolResponse{
		OrderID:        req.OrderID,
		Status:         "shipped",
		EstimatedDeliv: "2025-04-10",
		Carrier:        "Telnyx Logistics",
	}

	writeJSON(w, resp)
}

func writeJSON(w http.ResponseWriter, v any) {
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(v)
}

Step 4: Ship the function

telnyx-edge ship
The ship process takes 2–3 minutes. After uploaded successfully, poll telnyx-edge list until the status shows deploy_ok. Don’t trust a CLI timeout as a failure — the function may still be building server-side.
telnyx-edge list
# FUNC ID                          FUNCTION NAME    STATUS      INVOKE URL
# 917210e7-...                     telnyx-ai-edge   deploy_ok  https://telnyx-ai-edge-<org>.telnyxcompute.com
Save the invoke URL — you’ll point the assistant at it next.

Step 5: Configure the AI Assistant

Set the function URL as both the dynamic variables webhook URL and the webhook tool URL on the assistant.

Dynamic variables webhook

In the Portal or via the API:
FieldValue
dynamic_variables_webhook_urlhttps://telnyx-ai-edge-<org>.telnyxcompute.com/
dynamic_variables_webhook_timeout_ms8000
Consider setting the timeout to 8,000 ms to give the function room on cold starts. The default 1,500 ms may be tight for a cold function.

Template variables in the assistant

Use {{variable_name}} in the assistant’s instructions and greeting to reference the variables your function returns:
instructions: "You are a support agent for Telnyx Logistics. The caller is {{customer_name}} (tier: {{account_tier}}). They may have open order {{open_order_id}}."
greeting: "Hi {{customer_name}}, thanks for calling Telnyx Logistics. How can I help you today?"

Webhook tool

Add a webhook tool that points to the same function URL:
{
  "type": "webhook",
  "webhook": {
    "name": "lookup-order",
    "description": "Look up the current status of a customer order by its order id.",
    "url": "https://telnyx-ai-edge-<org>.telnyxcompute.com/",
    "method": "POST",
    "body_parameters": {
      "type": "object",
      "properties": {
        "order_id": {
          "type": "string",
          "description": "The order id to look up, e.g. ORD-10042."
        }
      },
      "required": ["order_id"]
    }
  }
}
When the LLM decides to call lookup-order, Telnyx sends a POST with the tool arguments as the flat body ({"order_id": "ORD-10042"}), signed with the same Ed25519 key. Your function detects the body shape, handles it as a tool call, and returns the result.

Step 6: Test end-to-end

  1. Call the function directly (without a signature — it’ll return 403, confirming it’s live):
    curl -X POST https://telnyx-ai-edge-<org>.telnyxcompute.com/ \
      -H "Content-Type: application/json" \
      -d '{"order_id":"ORD-10042"}'
    # → 403 invalid signature  ← expected, signature verification is working
    
  2. Make a test call to the assistant from the Portal or via the API:
    curl --request POST \
      --url https://api.telnyx.com/v2/texml/ai_calls/<texml_app_id> \
      --header "Authorization: Bearer $TELNYX_API_KEY" \
      --header 'Content-Type: application/json' \
      --data '{
        "From": "+13128675309",
        "To": "+15551234567",
        "AIAssistantId": "assistant-<id>"
      }'
    
  3. Verify in the conversation transcript that:
    • The greeting includes the resolved customer_name
    • The assistant can call lookup-order and read back real order data

Tips and gotchas

Choosing body-shape vs path-based dispatch

Since Edge Compute routes all paths to your handler, you can use path-based routing (e.g. r.URL.Path == "/tool/lookup-order") or body-shape dispatch as shown in this guide. Both work. If you configure separate URLs for the DV webhook and the tool on the assistant, path-based routing is natural. If you point both at the same URL, body-shape dispatch is the way to go. For a path-based routing example, see the RESTful API example in the Edge Compute CLI repo.

Consider a higher webhook timeout

The default dynamic variables webhook timeout is 1,500 ms. Edge Compute functions may occasionally need a bit more time on a cold start, so consider setting dynamic_variables_webhook_timeout_ms to 8,000 ms to give the function room. The maximum is 10,000 ms.

Always verify signatures

Without signature verification, anyone who knows your function URL can inject fake dynamic variables or tool responses. The telnyx-signature-ed25519 and telnyx-timestamp headers are present on every request from Telnyx.

Ship takes a few minutes

A normal ship takes 2–3 minutes. The CLI’s build monitor has a 5-minute timeout, but the build continues server-side regardless. If the CLI reports a timeout, check telnyx-edge list for the actual status before retrying — the function may have deployed successfully.

Secrets require re-shipping

Adding or changing a secret (telnyx-edge secrets add) does not affect an already-deployed function. Run telnyx-edge ship again to pick up the new secret.

The dynamic_variables wrapper is mandatory

Returning a flat JSON object like {"customer_name": "James"} will be silently ignored. Variables must be nested under dynamic_variables:
{
  "dynamic_variables": {
    "customer_name": "James"
  }
}

Next steps

  • Dynamic Variables — full reference for the DV webhook payload and resolution precedence.
  • Webhook signing — how Telnyx signs webhooks and how to verify signatures.
  • Edge Compute quickstart — getting started with your first function.
  • Secrets — encrypted, org-scoped environment variables.
  • Bindings — pre-authenticated Telnyx API client for your function.