Webhooks Reference
Threll delivers events to your HTTPS endpoint as JSON. Async events are fire-and-forget; sync events expect a JSON response that influences the call.
Pick an event or topic from the sidebar.
Threll delivers events to your HTTPS endpoint as JSON. Async events are fire-and-forget; sync events expect a JSON response that influences the call.
Pick an event or topic from the sidebar.
Every webhook delivery carries the following headers:
| Header | Description |
|---|---|
X-Threll-Event | Event type, e.g. call.transcript |
X-Threll-Event-Id | Unique id for this delivery attempt |
X-Threll-Timestamp | ISO 8601 timestamp when the event was generated |
X-Threll-Delivery | "sync" or "async" |
X-Threll-Account-Id | Your account ID |
X-Threll-Signature | Hex-encoded HMAC-SHA256 of the raw body, computed with the subscription signing secret |
Content-Type | application/json |
Pre-call hook fired right before a call starts. Your endpoint returns the worker configuration to use for the call.
Fires immediately before the outbound call starts (or on inbound entry) so scheduled calls re-resolve their configuration when they actually run.
Return JSON with the worker configuration overrides to merge onto the call before the voice session starts. When data.contextCarryover is present, Threll is about to give the worker context from that earlier call: return your own context to replace it, or suppressContextCarryover to drop it.
| Field | Type | Required | Description |
|---|---|---|---|
config | object | Partial worker configuration overrides (voice, llm, routing) merged onto the call before the session starts. | |
instructions | string | Full prompt for the worker. When provided, replaces the instructions generated from the worker call script. | |
tools | object[] | External tools the worker can call during this call. Invocations are delivered as signed call.tool_call webhooks to each tool deliveryUrl. | |
context | any | Call context merged into the prompt for this call. Takes precedence over any context Threll would otherwise carry over from a recent previous call to the same customer. Accepts a JSON object or a plain string. | |
contextExpiresAt | string | ISO 8601 timestamp after which the context you returned must no longer be used. Threll drops the context once this passes. | |
suppressContextCarryover | boolean | Set to true to reject the carry-over context announced in data.contextCarryover, so the worker starts this call with no memory of the previous one. Ignored when you also return a context of your own, which replaces the carry-over anyway. | |
metadata | object | Opaque key/value bag. Threll persists it on the call and echoes it back on every subsequent call.* event so you can correlate events with your own records. |
{
"instructions": "You are calling Acme Corp to follow up on their demo request. Be concise and friendly.",
"config": {
"voiceId": "Kore",
"firstSpeechTimeout": 2
},
"tools": [
{
"name": "lookup_account",
"description": "Look up an account by its number.",
"inputSchema": {
"type": "object",
"properties": {
"accountNumber": {
"type": "string"
}
},
"required": [
"accountNumber"
]
},
"deliveryUrl": "https://example.com/threll/tools",
"signingSecret": "whsec_your_tool_signing_secret"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
type | "call.worker_request" | yes | |
data | object | yes | |
data.callId | string | yes | Unique identifier for the phone call |
data.accountId | string | yes | Your account ID |
data.workerId | string | yes | Worker that will handle the call |
data.direction | "inbound" | "outbound" | yes | Call direction |
data.transport | string | Call transport (e.g. twilio, sip_trunk, webrtc) | |
data.customer | object | Remote party identity if known at request time | |
data.customer.number | string | Remote phone number in E.164 format | |
data.customer.name | string | null | Customer name if known | |
data.customer.personId | string | null | Threll person record identifier if matched | |
data.customer.language | string | null | BCP-47 language tag | |
data.contextCarryover | object | Context Threll resolved from a recent previous call to this customer and will inject unless you override it. Absent when there is nothing to carry over. Return your own context to replace it, or suppressContextCarryover to drop it. | |
data.contextCarryover.sourceCallId | string | yes | Identifier of the previous call this context came from. Matches the callId you received on that call. |
data.contextCarryover.freshness | "hot" | "warm" | yes | How the worker is allowed to use the previous call. "hot" — it may raise the previous call itself, for example to thank the caller for calling back. "warm" — background only, used just if the caller raises the topic first. |
data.contextCarryover.ageHours | number | yes | Hours since the previous call, rounded to one decimal. |
data.contextCarryover.occurredAt | string | yes | ISO 8601 timestamp of when the previous call took place |
data.contextCarryover.expiresAt | string | ISO 8601 timestamp the previous context declared for itself, when it set one. Threll stops carrying the context once this passes. | |
data.metadata | object | Opaque metadata supplied when the call was created (e.g. via web-calls), echoed here so you can correlate this pre-call hook. Absent if none was set. |
{
"type": "call.worker_request",
"data": {
"callId": "5f1a3e7b-2c8d-49a6-b0e4-7d9f3c5a1b2e",
"accountId": "9c3e8f02-7a14-4b62-bc59-1d8e5fa3027b",
"workerId": "2b7d9a4c-6e3f-48a1-9c5d-8e0b4f2a1c8d",
"direction": "outbound",
"customer": {
"number": "+15555550100",
"name": "Acme Corp"
}
}
}Mid-call external tool dispatch. Threll forwards a tool call from the worker to your endpoint, which returns the tool result.
Fires every time the worker invokes an externally-registered tool (provider="external") during an active call. The voice session pauses on the response.
Return JSON with the tool result that should be presented back to the worker.
| Field | Type | Required | Description |
|---|---|---|---|
result | any | The tool result presented back to the worker. Any JSON value. |
{
"result": {
"accountNumber": "12345",
"name": "Acme Corp",
"plan": "enterprise",
"balanceDue": 0
}
}| Field | Type | Required | Description |
|---|---|---|---|
type | "call.tool_call" | yes | |
data | object | yes | |
data.callId | string | yes | Phone call identifier |
data.accountId | string | yes | Your account ID |
data.workerId | string | yes | Worker that invoked the tool |
data.toolCallId | string | yes | Unique id for this tool call; use to correlate logs |
data.name | string | yes | Name of the externally-registered tool |
data.arguments | object | yes | JSON arguments the worker passed in |
data.metadata | object | Opaque metadata you returned from call.worker_request, echoed back for correlation. Absent if you did not set any. |
{
"type": "call.tool_call",
"data": {
"callId": "5f1a3e7b-2c8d-49a6-b0e4-7d9f3c5a1b2e",
"accountId": "9c3e8f02-7a14-4b62-bc59-1d8e5fa3027b",
"workerId": "2b7d9a4c-6e3f-48a1-9c5d-8e0b4f2a1c8d",
"toolCallId": "c8d4e9f2-1a3b-4c7d-8e0f-9a2b3c4d5e6f",
"name": "lookup_account",
"arguments": {
"accountNumber": "12345"
}
}
}A transcript turn (final or interim depending on the worker config) recorded during the call.
Streamed as transcript turns are committed. Fire-and-forget delivery; no response expected.
| Field | Type | Required | Description |
|---|---|---|---|
type | "call.transcript" | yes | |
data | object | yes | |
data.callId | string | yes | Phone call identifier |
data.turnIndex | integer | yes | Monotonic turn index within the call |
data.role | "user" | "agent" | yes | Who produced this turn |
data.text | string | yes | Turn text |
data.isFinal | boolean | yes | true when this turn is final; false for partial/interim |
data.confidence | number | null | Transcription confidence (0-1). Null for engines that do not report it (e.g. Gemini). | |
data.source | string | null | Engine that produced the text (e.g. gemini, whisper-nb). | |
data.ts | string | yes | ISO 8601 timestamp |
data.metadata | object | Opaque metadata you returned from call.worker_request, echoed back for correlation. Absent if you did not set any. |
{
"type": "call.transcript",
"data": {
"callId": "5f1a3e7b-2c8d-49a6-b0e4-7d9f3c5a1b2e",
"turnIndex": 0,
"role": "user",
"text": "Hi, I have a question about your enterprise plan.",
"isFinal": true,
"confidence": 0.92,
"source": "whisper-nb",
"ts": "2026-06-08T10:00:15Z"
}
}Lifecycle status change for the call (ringing, in_progress, etc.).
Fires whenever the call status changes during its lifecycle. Fire-and-forget.
| Field | Type | Required | Description |
|---|---|---|---|
type | "call.status_update" | yes | |
data | object | yes | |
data.callId | string | yes | Phone call identifier |
data.status | string | yes | Threll-normalized call status |
data.providerStatus | string | null | Underlying provider status (Twilio/Jambonz/etc.) when available | |
data.ts | string | yes | ISO 8601 timestamp of the status change |
data.metadata | object | Opaque metadata you returned from call.worker_request, echoed back for correlation. Absent if you did not set any. |
{
"type": "call.status_update",
"data": {
"callId": "5f1a3e7b-2c8d-49a6-b0e4-7d9f3c5a1b2e",
"status": "in_progress",
"providerStatus": "ringing",
"ts": "2026-06-08T10:00:15Z"
}
}Final event for the call, delivered exactly once. Once you receive this, no further events for the call will be delivered. When status is "failed", failedReason names the cause (see Failure reasons).
Fires once a dialed call reaches a terminal status: completed, voicemail, no_answer, busy, canceled or failed. For a call that was answered and recorded, it fires after post-processing, so transcript and nextAction are populated. For a call that never connected — voicemail, no answer, a failed dial — there is nothing to analyse, so those fields are omitted and only the call metadata is present. A scheduled call that is canceled before dialing starts does not emit this event; track those via its status instead.
| Field | Type | Required | Description |
|---|---|---|---|
type | "call.ended" | yes | |
data | object | yes | |
data.callId | string | yes | Phone call identifier |
data.status | string | yes | Final call status |
data.failedReason | "missing_phone_numbers" | "worker_request_required_failed" | "voice_session_unavailable" | "voice_session_creation_failed" | "twilio_call_failed" | "jambonz_call_failed" | "webrtc_client_never_connected" | "cancelled_by_account_admin" | null | Why the call failed. Set only when status is "failed", and absent for calls that failed at the carrier without a known reason. See Failure reasons for what each value means. | |
data.accountId | string | yes | Your account ID |
data.workerId | string | yes | Worker that handled the call |
data.direction | "inbound" | "outbound" | Call direction | |
data.fromNumber | string | null | Caller number in E.164, when available | |
data.toNumber | string | null | Callee number in E.164, when available | |
data.duration | number | null | Call duration in seconds | |
data.scheduledAt | string | null | ISO 8601 | |
data.startedAt | string | null | ISO 8601 | |
data.endedAt | string | null | ISO 8601 | |
data.transcript | any | Full transcript turns, when available | |
data.recordingAvailable | boolean | yes | true when a recording exists; recording then says how to download it. |
data.recording | object | null | yes | Download details for the recording. null when recordingAvailable is false. |
data.recording.url | string | yes | Link to download the recording without an API key, valid for 7 days. It redirects to a short-lived storage URL, so follow redirects (e.g. curl -L). It stops working early if the recording is deleted, or if the webhook subscription is disabled, deleted or has its signing secret rotated. |
data.recording.urlExpiresAt | string | yes | ISO 8601 time after which url no longer works |
data.recording.contentType | string | yes | Media type of the recording file, e.g. "audio/wav" |
data.callScript | object | null | yes | The script the call ran on. null for calls created before scripts were recorded. |
data.callScript.source | "documents" | "custom_instructions" | "worker_request" | "signup" | yes | Where the call instructions came from: "documents" (the worker's call script documents, or those picked for the call), "custom_instructions" (inline instructions given when the call was created), "worker_request" (instructions returned by your call.worker_request webhook) or "signup" (generated during account signup). |
data.callScript.contentHash | string | null | yes | Hash of the whole script the call ran on, whatever its source, so two calls with the same value ran on the same script. It covers the script text, not the per-call details such as the date or the customer. Written as "<algorithm>:<hex>", currently "sha256:<hex>". Compare it as an opaque string: equal values mean identical text. null for calls created before 2026-09-24. |
data.callScript.documents | object[] | yes | Script documents the script was built from, in the order they appear in it. Empty unless source is "documents". |
data.nextAction | object | The next action resolved for the call, when one was configured. | |
data.nextAction.key | string | yes | Machine key of the resolved next action |
data.nextAction.name | string | yes | Human-readable next-action name |
data.nextAction.data | object | Structured data captured for the next action | |
data.contextSourceCallId | string | null | Identifier of the previous call whose context was carried into this one, when carry-over applied. Absent for calls that started with no carried-over context. | |
data.personId | string | null | Threll person record identifier, when matched | |
data.metadata | object | Opaque metadata you returned from call.worker_request, echoed back for correlation. Absent if you did not set any. |
{
"type": "call.ended",
"data": {
"callId": "5f1a3e7b-2c8d-49a6-b0e4-7d9f3c5a1b2e",
"status": "completed",
"failedReason": null,
"accountId": "9c3e8f02-7a14-4b62-bc59-1d8e5fa3027b",
"workerId": "2b7d9a4c-6e3f-48a1-9c5d-8e0b4f2a1c8d",
"direction": "outbound",
"fromNumber": "+15555550111",
"toNumber": "+15555550100",
"duration": 96,
"startedAt": "2026-06-08T10:00:00Z",
"endedAt": "2026-06-08T10:01:36Z",
"recordingAvailable": true,
"recording": {
"url": "https://api.threll.io/recordings/5f1a3e7b-2c8d-49a6-b0e4-7d9f3c5a1b2e?sub=7c1d2e3f-4a5b-46c7-8d9e-0f1a2b3c4d5e&exp=1781517696&sig=4f2a…",
"urlExpiresAt": "2026-06-15T10:01:36Z",
"contentType": "audio/wav"
},
"callScript": {
"source": "documents",
"contentHash": "sha256:9b1f0c4e7a2d5b8c3e6f9a0d1c4b7e2a5d8f1c3b6e9a2d4f7c0b3e5a8d1f4c6b",
"documents": [
{
"documentId": "e4f5a6b7-8c9d-40e1-a2b3-c4d5e6f7a8b9",
"title": "Appointment reminder",
"contentHash": "sha256:3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b"
}
]
},
"personId": "b2c3d4e5-6f7a-48b9-0c1d-2e3f4a5b6c7d"
}
}When a call ends with status failed, thecall.ended payload carriesdata.failedReason. It is absent when the call failed at the carrier without a reason Threll could classify.
| Value | Meaning |
|---|---|
missing_phone_numbers | The call had no from or to number set, so it could never be placed. |
worker_request_required_failed | A required call.worker_request hook failed or aborted, so the call was not started. |
voice_session_unavailable | The Threll voice session could not be created for an outbound carrier call (voice server misconfigured or unavailable). |
voice_session_creation_failed | The Threll voice session could not be created for a web (browser) call. |
twilio_call_failed | Placing the outbound call via Twilio failed before the carrier call was created (missing integration, auth, or Twilio API error). |
jambonz_call_failed | Placing the outbound call via the SIP/Jambonz provider failed before the carrier call was created. |
webrtc_client_never_connected | A scheduled web call expired because the browser client never connected. |
cancelled_by_account_admin | A scheduled call was cancelled by an account admin before it ran. |
Every delivery is signed with the subscription's signing secret usingHMAC-SHA256 over the raw request body. Compare the hex digest to the X-Threll-Signature header using a constant-time comparison.
echo -n "$RAW_REQUEST_BODY" | \
openssl dgst -sha256 -hmac "whsec_..." | \
awk '{print $2}'Async events are delivered fire-and-forget via the event bus; sync events are delivered inline and the response is fed back into the call.
X-Threll-Event-Id; treat handlers as idempotent.Use the subscription Test action in Settings → Developer (or POST /v1/accounts/{accountId}/webhook-subscriptions/{id}/test) to dispatch a synthetic event matching the subscription's event type.