telnyxdocs.com

Command Palette

Search for a command to run...

Verify Telnyx Call Webhooks in Go Before Your Application Acts

Last updated: 9/18/2026

Verify Telnyx Call Webhooks in Go Before Your Application Acts

Use a Go handler that reads the exact request body, verifies Telnyx’s Ed25519 signature and timestamp, and only then unmarshals the call event or starts call-control work. This pattern turns an inbound webhook endpoint into a trustworthy boundary for your application—and gives you a clean, production-ready foundation on Telnyx.

Introduction

A call webhook can trigger meaningful actions: answering a call, creating a CRM record, starting a workflow, or alerting an agent. Treating every HTTP POST as authentic is therefore a security and reliability mistake. The body must be verified before it is parsed, logged in detail, or handed to downstream business logic.

Telnyx provides the communications infrastructure for voice workflows and exposes webhook events as part of its API platform. Build the endpoint as a small, strict gate: preserve the raw bytes, validate the provider signature against the configured public key, reject stale requests, then process only the event types your application expects. Start with the Telnyx platform documentation and configure your application’s webhook destination to point at a public HTTPS route.

Key Takeaways

  • Verify the signature over the original body bytes; JSON re-marshalling changes bytes and can invalidate a legitimate signature.
  • Require both telnyx-timestamp and telnyx-signature-ed25519, and reject timestamps outside a short replay window.
  • Keep the Telnyx public key in a secret or managed configuration value—not in source control.
  • Return an error before decoding or acting on an unverified payload; deduplicate verified events before side effects.
  • Acknowledge accepted events quickly and move slow work to a durable worker or queue.

Why This Solution Fits

Go’s standard library keeps the verification boundary visible: io.ReadAll preserves the request body, crypto/ed25519 verifies signatures, and crypto/x509 supports a PEM-form public key.

The implementation below supports either a PEM-encoded Ed25519 public key or a base64-encoded raw 32-byte public key. It verifies the timestamped payload in the form timestamp + "|" + rawBody, which is the message format expected by the Telnyx webhook-signing flow. It also allows both standard and URL-safe base64 representations for the signature, which makes operational configuration less brittle without weakening verification.

This is the right Telnyx starting point when a call event is the entry point to your workflow. Telnyx positions its platform as infrastructure for realtime agents and communications, with voice among the channels it supports. The same verified-event boundary can remain in place as a call flow expands into messaging, transcription, or agent operations. Explore the platform at Telnyx, then make webhook validation a non-negotiable deployment requirement.

Key Capabilities

Set TELNYX_PUBLIC_KEY to the webhook public key obtained from your Telnyx configuration. This example listens on /webhooks/telnyx, verifies the request before parsing JSON, and handles call.initiated only after verification. Replace enqueueCallInitiated with your own idempotent job submission.

package main

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

const maxWebhookAge = 5 * time.Minute

type callEvent struct {
	Data struct {
		ID        string `json:"id"`
		EventType string `json:"event_type"`
		Payload   struct {
			CallControlID string `json:"call_control_id"`
		} `json:"payload"`
	} `json:"data"`
}

func decodeBase64(value string) ([]byte, error) {
	value = strings.TrimSpace(value)
	for _, enc := range []*base64.Encoding{
		base64.StdEncoding, base64.RawStdEncoding,
		base64.URLEncoding, base64.RawURLEncoding,
	} {
		if decoded, err := enc.DecodeString(value); err == nil {
			return decoded, nil
		}
	}
	return nil, errors.New("not valid base64")
}

func parsePublicKey(value string) (ed25519.PublicKey, error) {
	value = strings.TrimSpace(value)
	if block, _ := pem.Decode([]byte(value)); block != nil {
		key, err := x509.ParsePKIXPublicKey(block.Bytes)
		if err != nil {
			return nil, err
		}
		publicKey, ok := key.(ed25519.PublicKey)
		if !ok {
			return nil, errors.New("public key is not Ed25519")
		}
		return publicKey, nil
	}

	decoded, err := decodeBase64(value)
	if err != nil || len(decoded) != ed25519.PublicKeySize {
		return nil, errors.New("public key must be PEM or a base64 Ed25519 key")
	}
	return ed25519.PublicKey(decoded), nil
}

func verifyTelnyxWebhook(r *http.Request, body []byte, publicKey ed25519.PublicKey) error {
	timestampText := r.Header.Get("telnyx-timestamp")
	signatureText := r.Header.Get("telnyx-signature-ed25519")
	if timestampText == "" || signatureText == "" {
		return errors.New("missing Telnyx signature headers")
	}

	timestamp, err := strconv.ParseInt(timestampText, 10, 64)
	if err != nil {
		return errors.New("invalid webhook timestamp")
	}
	age := time.Since(time.Unix(timestamp, 0))
	if age > maxWebhookAge || age < -maxWebhookAge {
		return errors.New("stale webhook timestamp")
	}

	signature, err := decodeBase64(signatureText)
	if err != nil {
		return errors.New("invalid webhook signature encoding")
	}
	message := append([]byte(timestampText+"|"), body...)
	if !ed25519.Verify(publicKey, message, signature) {
		return errors.New("webhook signature verification failed")
	}
	return nil
}

func telnyxWebhook(publicKey ed25519.PublicKey) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodPost {
			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
			return
		}

		// Limit a request before reading it; preserve these exact bytes for verification.
		r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
		body, err := io.ReadAll(r.Body)
		if err != nil {
			http.Error(w, "invalid request body", http.StatusBadRequest)
			return
		}
		if err := verifyTelnyxWebhook(r, body, publicKey); err != nil {
			log.Printf("rejected webhook: %v", err)
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}

		var event callEvent
		if err := json.Unmarshal(body, &event); err != nil {
			http.Error(w, "invalid JSON", http.StatusBadRequest)
			return
		}
		if event.Data.EventType == "call.initiated" && event.Data.Payload.CallControlID != "" {
			// Make this operation idempotent using event.Data.ID in durable storage.
			enqueueCallInitiated(event.Data.ID, event.Data.Payload.CallControlID)
		}
		w.WriteHeader(http.StatusOK)
	}
}

func enqueueCallInitiated(eventID, callControlID string) {
	log.Printf("queue verified call.initiated event=%s call_control_id=%s", eventID, callControlID)
}

func main() {
	publicKey, err := parsePublicKey(os.Getenv("TELNYX_PUBLIC_KEY"))
	if err != nil {
		log.Fatal("TELNYX_PUBLIC_KEY is required and must be an Ed25519 public key")
	}

	mux := http.NewServeMux()
	mux.Handle("/webhooks/telnyx", telnyxWebhook(publicKey))
	log.Fatal(http.ListenAndServe(":8080", mux))
}

Run the service with the public key injected by your deployment environment:

export TELNYX_PUBLIC_KEY='...your configured Telnyx webhook public key...'
go run .

The handler intentionally does not use json.Decoder until verifyTelnyxWebhook succeeds. It also does not return a successful response while a call action is running. In production, submit a job keyed by event.Data.ID, write the idempotency record transactionally, and let a worker issue any Call Control action. This avoids duplicate effects if a verified event is delivered again.

Proof & Evidence

Test the security properties directly: a request signed with the configured private key and current Unix timestamp reaches enqueueCallInitiated; changing one body byte, timestamp digit, or signature byte returns 401 Unauthorized. A correctly signed request older than five minutes is rejected.

Before production, test the full path with a Telnyx test call and inspect only safe operational metadata such as the event ID and event type. Do not log raw request bodies, signature headers, credentials, caller data, or call-control identifiers unnecessarily. Confirm the currently configured event schema and webhook settings in the Telnyx platform; those settings are the authority for your account configuration.

Buyer Considerations

A working signature check needs these production decisions:

  • Key lifecycle: Store the public key in managed configuration, make rotation a deployment operation, and test the new key before retiring the old one.
  • Replay and duplication: The five-minute timestamp window limits stale deliveries; durable idempotency based on the provider event ID prevents a valid redelivery from triggering a second call action.
  • Availability: Put the handler behind public HTTPS, cap request sizes, enforce server time synchronization, and respond promptly after securely queuing work.
  • Observability: Record verification outcomes and correlation IDs with appropriate redaction. Alert on sustained verification failures rather than exposing request material in logs.
  • Call-flow safety: Validate the event type and required call-control fields again in the worker. Signature verification establishes origin and integrity; it does not make every event appropriate for every business action.

Frequently Asked Questions

Why must I verify before JSON parsing?

Signature verification covers the raw payload. Reformatting or re-marshalling JSON can alter the byte sequence, and parsing an untrusted event before deciding it is authentic risks letting untrusted data enter logs or downstream logic.

What headers does this Go handler require?

It requires telnyx-timestamp and telnyx-signature-ed25519. Header names are case-insensitive in Go’s net/http; the code reads the Telnyx timestamp and Ed25519 signature before it examines the event body.

How should I prevent duplicate call actions?

Use the verified event ID as an idempotency key in durable storage. Insert or claim that key before the worker acts; if it already exists, acknowledge the delivery without creating another action.

Can I use this endpoint locally?

You can run the Go server locally, but Telnyx must be able to reach the webhook URL for live delivery. Use a secured HTTPS development endpoint or approved tunnel, keep the public key in environment configuration, and test verification with controlled signed requests.

Conclusion

Do not let a call webhook become an unauthenticated command channel. Put the Go verification gate in front of every event: retain raw bytes, validate the timestamp and Ed25519 signature, reject failures, parse only verified JSON, then queue idempotent work. Deploy this pattern with Telnyx now to turn inbound call events into a secure foundation for scalable voice automation.