Threll.ai
Webhooks

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.

Webhooks

Request headers

Every webhook delivery carries the following headers:

HeaderDescription
X-Threll-EventEvent type, e.g. call.transcript
X-Threll-Event-IdUnique id for this delivery attempt
X-Threll-TimestampISO 8601 timestamp when the event was generated
X-Threll-Delivery"sync" or "async"
X-Threll-Account-IdYour account ID
X-Threll-SignatureHex-encoded HMAC-SHA256 of the raw body, computed with the subscription signing secret
Content-Typeapplication/json
Webhook event · sync

call.worker_request

Pre-call hook fired right before a call starts. Your endpoint returns the worker configuration to use for the call.

When it fires

Fires immediately before the outbound call starts (or on inbound entry) so scheduled calls re-resolve their configuration when they actually run.

Expected response

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.

Response fields

FieldTypeRequiredDescription
configobjectPartial worker configuration overrides (voice, llm, routing) merged onto the call before the session starts.
instructionsstringFull prompt for the worker. When provided, replaces the instructions generated from the worker call script.
toolsobject[]External tools the worker can call during this call. Invocations are delivered as signed call.tool_call webhooks to each tool deliveryUrl.
contextanyCall 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.
contextExpiresAtstringISO 8601 timestamp after which the context you returned must no longer be used. Threll drops the context once this passes.
suppressContextCarryoverbooleanSet 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.
metadataobjectOpaque 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.

Example response

call.worker_request response
{
  "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"
    }
  ]
}

Payload fields

FieldTypeRequiredDescription
type"call.worker_request"yes
dataobjectyes
data.callIdstringyesUnique identifier for the phone call
data.accountIdstringyesYour account ID
data.workerIdstringyesWorker that will handle the call
data.direction"inbound" | "outbound"yesCall direction
data.transportstringCall transport (e.g. twilio, sip_trunk, webrtc)
data.customerobjectRemote party identity if known at request time
data.customer.numberstringRemote phone number in E.164 format
data.customer.namestring | nullCustomer name if known
data.customer.personIdstring | nullThrell person record identifier if matched
data.customer.languagestring | nullBCP-47 language tag
data.contextCarryoverobjectContext 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.sourceCallIdstringyesIdentifier of the previous call this context came from. Matches the callId you received on that call.
data.contextCarryover.freshness"hot" | "warm"yesHow 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.ageHoursnumberyesHours since the previous call, rounded to one decimal.
data.contextCarryover.occurredAtstringyesISO 8601 timestamp of when the previous call took place
data.contextCarryover.expiresAtstringISO 8601 timestamp the previous context declared for itself, when it set one. Threll stops carrying the context once this passes.
data.metadataobjectOpaque 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.

Example payload

call.worker_request
{
  "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"
    }
  }
}
Webhook event · sync

call.tool_call

Mid-call external tool dispatch. Threll forwards a tool call from the worker to your endpoint, which returns the tool result.

When it fires

Fires every time the worker invokes an externally-registered tool (provider="external") during an active call. The voice session pauses on the response.

Expected response

Return JSON with the tool result that should be presented back to the worker.

Response fields

FieldTypeRequiredDescription
resultanyThe tool result presented back to the worker. Any JSON value.

Example response

call.tool_call response
{
  "result": {
    "accountNumber": "12345",
    "name": "Acme Corp",
    "plan": "enterprise",
    "balanceDue": 0
  }
}

Payload fields

FieldTypeRequiredDescription
type"call.tool_call"yes
dataobjectyes
data.callIdstringyesPhone call identifier
data.accountIdstringyesYour account ID
data.workerIdstringyesWorker that invoked the tool
data.toolCallIdstringyesUnique id for this tool call; use to correlate logs
data.namestringyesName of the externally-registered tool
data.argumentsobjectyesJSON arguments the worker passed in
data.metadataobjectOpaque metadata you returned from call.worker_request, echoed back for correlation. Absent if you did not set any.

Example payload

call.tool_call
{
  "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"
    }
  }
}
Webhook event · async

call.transcript

A transcript turn (final or interim depending on the worker config) recorded during the call.

When it fires

Streamed as transcript turns are committed. Fire-and-forget delivery; no response expected.

Payload fields

FieldTypeRequiredDescription
type"call.transcript"yes
dataobjectyes
data.callIdstringyesPhone call identifier
data.turnIndexintegeryesMonotonic turn index within the call
data.role"user" | "agent"yesWho produced this turn
data.textstringyesTurn text
data.isFinalbooleanyestrue when this turn is final; false for partial/interim
data.confidencenumber | nullTranscription confidence (0-1). Null for engines that do not report it (e.g. Gemini).
data.sourcestring | nullEngine that produced the text (e.g. gemini, whisper-nb).
data.tsstringyesISO 8601 timestamp
data.metadataobjectOpaque metadata you returned from call.worker_request, echoed back for correlation. Absent if you did not set any.

Example payload

call.transcript
{
  "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"
  }
}
Webhook event · async

call.status_update

Lifecycle status change for the call (ringing, in_progress, etc.).

When it fires

Fires whenever the call status changes during its lifecycle. Fire-and-forget.

Payload fields

FieldTypeRequiredDescription
type"call.status_update"yes
dataobjectyes
data.callIdstringyesPhone call identifier
data.statusstringyesThrell-normalized call status
data.providerStatusstring | nullUnderlying provider status (Twilio/Jambonz/etc.) when available
data.tsstringyesISO 8601 timestamp of the status change
data.metadataobjectOpaque metadata you returned from call.worker_request, echoed back for correlation. Absent if you did not set any.

Example payload

call.status_update
{
  "type": "call.status_update",
  "data": {
    "callId": "5f1a3e7b-2c8d-49a6-b0e4-7d9f3c5a1b2e",
    "status": "in_progress",
    "providerStatus": "ringing",
    "ts": "2026-06-08T10:00:15Z"
  }
}
Webhook event · async

call.ended

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).

When it fires

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.

Payload fields

FieldTypeRequiredDescription
type"call.ended"yes
dataobjectyes
data.callIdstringyesPhone call identifier
data.statusstringyesFinal 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" | nullWhy 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.accountIdstringyesYour account ID
data.workerIdstringyesWorker that handled the call
data.direction"inbound" | "outbound"Call direction
data.fromNumberstring | nullCaller number in E.164, when available
data.toNumberstring | nullCallee number in E.164, when available
data.durationnumber | nullCall duration in seconds
data.scheduledAtstring | nullISO 8601
data.startedAtstring | nullISO 8601
data.endedAtstring | nullISO 8601
data.transcriptanyFull transcript turns, when available
data.recordingAvailablebooleanyestrue when a recording exists; recording then says how to download it.
data.recordingobject | nullyesDownload details for the recording. null when recordingAvailable is false.
data.recording.urlstringyesLink 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.urlExpiresAtstringyesISO 8601 time after which url no longer works
data.recording.contentTypestringyesMedia type of the recording file, e.g. "audio/wav"
data.callScriptobject | nullyesThe script the call ran on. null for calls created before scripts were recorded.
data.callScript.source"documents" | "custom_instructions" | "worker_request" | "signup"yesWhere 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.contentHashstring | nullyesHash 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.documentsobject[]yesScript documents the script was built from, in the order they appear in it. Empty unless source is "documents".
data.nextActionobjectThe next action resolved for the call, when one was configured.
data.nextAction.keystringyesMachine key of the resolved next action
data.nextAction.namestringyesHuman-readable next-action name
data.nextAction.dataobjectStructured data captured for the next action
data.contextSourceCallIdstring | nullIdentifier 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.personIdstring | nullThrell person record identifier, when matched
data.metadataobjectOpaque metadata you returned from call.worker_request, echoed back for correlation. Absent if you did not set any.

Example payload

call.ended
{
  "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"
  }
}
Reference

Failure reasons

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.

ValueMeaning
missing_phone_numbersThe call had no from or to number set, so it could never be placed.
worker_request_required_failedA required call.worker_request hook failed or aborted, so the call was not started.
voice_session_unavailableThe Threll voice session could not be created for an outbound carrier call (voice server misconfigured or unavailable).
voice_session_creation_failedThe Threll voice session could not be created for a web (browser) call.
twilio_call_failedPlacing the outbound call via Twilio failed before the carrier call was created (missing integration, auth, or Twilio API error).
jambonz_call_failedPlacing the outbound call via the SIP/Jambonz provider failed before the carrier call was created.
webrtc_client_never_connectedA scheduled web call expired because the browser client never connected.
cancelled_by_account_adminA scheduled call was cancelled by an account admin before it ran.
Security

Signature verification

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.

Verify in bash
echo -n "$RAW_REQUEST_BODY" | \
  openssl dgst -sha256 -hmac "whsec_..." | \
  awk '{print $2}'
Delivery

Delivery & retries

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.

  • Sync deliveries time out after a few seconds. Return JSON quickly; on error or timeout, the call proceeds with defaults (unless the subscription is marked required).
  • Async deliveries are retried with exponential backoff on non-2xx responses.
  • Every delivery has a unique X-Threll-Event-Id; treat handlers as idempotent.
Delivery

Testing

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.