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
-
TelnyxWebhookDispatcherinlib/events/telnyx-event-handler.ts - Persistence
-
WebhookEventRepository.record()inlib/repositories/webhook-event-repository.ts
Route flow
-
Require
TELNYX_PUBLIC_KEY. When it is unset, return503. -
Verify the Ed25519 signature in
telnyx-signature-ed25519over{telnyx-timestamp}|{rawBody}. Reject a missing header, a bad signature, or a timestamp more than five minutes off with401. Only the reason (missing_headers,stale_timestamp,invalid_signature) is logged. -
Decode the body with
decodeTelnyxBody(JSON or form-encoded, see below). An undecodable body returns400 { error: "Invalid body" }. -
Upsert a
WebhookEventrow withsource = "Telnyx", idempotent on the event id. -
Dispatch through
TelnyxWebhookDispatcher. No handlers are registered, so every event type is stored and acknowledged with200 { 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.2430becomes{ "CallCost": { "v3:abc": "0.2430" } }.BilledDurationSeconds[…]is decoded the same way. -
A value that starts with
{or[and parses as JSON, such asMessagesorCallQualityStats, 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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
none of the above |
|
Correlation and idempotency
providerCallId:
-
JSON:
payload.conversation_id, thenpayload.call_control_id, thenpayload.metadata.call_control_id.data.payloadis preferred over a top-levelpayload. -
Form:
ConversationId, thenCallControlId, thenCallSid.
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.