Telnyx AI assistant events

Telnyx posts AI assistant call events to a dedicated endpoint, as JSON or form-encoded. The route verifies the signature, decodes the body, stores it, and dispatches through an empty registry. No call, campaign, or CRM work happens yet.

Endpoint

POST /api/webhooks/telnyx

Dispatcher

TelnyxWebhookDispatcher in lib/events/telnyx-event-handler.ts

Persistence

WebhookEventRepository.record() in lib/repositories/webhook-event-repository.ts

Route flow

  1. Require TELNYX_PUBLIC_KEY. When it is unset, return 503.

  2. Verify the Ed25519 signature in telnyx-signature-ed25519 over {telnyx-timestamp}|{rawBody}. Reject a missing header, a bad signature, or a timestamp more than five minutes off with 401. Only the reason (missing_headers, stale_timestamp, invalid_signature) is logged.

  3. Decode the body with decodeTelnyxBody (JSON or form-encoded, see below). An undecodable body returns 400 { error: "Invalid body" }.

  4. Upsert a WebhookEvent row with source = "Telnyx", idempotent on the event id.

  5. Dispatch through TelnyxWebhookDispatcher. No handlers are registered, so every event type is stored and acknowledged with 200 { received: true, event_type }.

A database failure returns 500 so Telnyx retries. The unique key on (source, eventId) makes that retry safe.

Raw bodies are never logged. They contain phone numbers, conversation transcripts (Messages), and pre-signed recording URLs. The route logs only the event type, format, event id, and providerCallId.

Body formats

Telnyx sends two formats to the same endpoint. decodeTelnyxBody picks one from Content-Type. Without a usable header, it treats a body starting with { (or an empty body) as JSON and anything else as form-encoded.

JSON

AI insight results (conversation_insight_result) and other JSON events.

Form-encoded (application/x-www-form-urlencoded)

Call-progress, call-cost, conversation, and recording callbacks.

Form decoding:

  • A bracketed key such as CallCost[v3:abc]=0.2430 becomes { "CallCost": { "v3:abc": "0.2430" } }. BilledDurationSeconds[…​] is decoded the same way.

  • A value that starts with { or [ and parses as JSON, such as Messages or CallQualityStats, is stored parsed. Every other value stays a string.

The decoded object is what is stored in rawPayload.

Event types

JSON events use data.event_type, then event_type, type, and event.

Form callbacks carry no event type, so the route derives one. These names are ours, not Telnyx’s; register handlers against them.

Fields present Derived eventType Examples

RecordingStatus

recording.<RecordingStatus>

recording.completed

CallbackSource and CallStatus

<CallbackSource>.<CallStatus>

call-progress-events.in-progress, call-progress-events.completed, call-cost-events.completed

CallStatus only

<CallStatus>

conversation_created, conversation_messages_added, conversation_ended

none of the above

unknown

Correlation and idempotency

providerCallId:

  • JSON: payload.conversation_id, then payload.call_control_id, then payload.metadata.call_control_id. data.payload is preferred over a top-level payload.

  • Form: ConversationId, then CallControlId, then CallSid.

eventId is data.id or id when Telnyx sends one. Otherwise it is sha256:<hex of the raw body>, and eventIdGenerated is true. Form callbacks never carry an id, so they always use the hash. A Telnyx retry resends the same body, so the hash dedupes it. Two different callbacks, for example two conversation_messages_added deliveries, have different bodies and never collide.

Examples

A JSON event wrapped in data:

{
  "data": {
    "record_type": "event",
    "event_type": "call.conversation.created",
    "id": "eb8775a6-634f-48b0-b177-d5465a8a8e9f",
    "payload": {
      "call_control_id": "v3:example",
      "conversation_id": "0424805b-adc1-4ff8-9f95-e1de6883ecbe"
    }
  }
}

A form-encoded call-cost callback, shown decoded. The derived event type is call-cost-events.completed and providerCallId is the CallSid:

{
  "CallbackSource": "call-cost-events",
  "CallStatus": "completed",
  "CallSid": "v3:example",
  "CallSessionId": "9760cbf0-0000-0000-0000-000000000000",
  "CallCost": { "v3:example": "0.2430" },
  "BilledDurationSeconds": { "v3:example": "60" },
  "Direction": "outbound-api"
}

Handlers

None. Register a handler on TelnyxWebhookDispatcher once stored rows show which event types matter. See Adding a handler.