---
title: "Events Catalog"
description: "All webhook event types ThunderPhone emits."
---

Every webhook body has a `type` field whose value is one of the event
types on this page. When you subscribe to an
[endpoint](/webhooks/endpoints), the `events` array must contain the
event types you want (or be empty to subscribe to everything —
except the per-turn events [`telephony.turn`](#telephony-turn) /
[`web.turn`](#web-turn), which are delivered only to endpoints that
name them explicitly).

Two delivery styles carry these events:

* **Endpoint deliveries** are always **non-blocking** notifications
  with [retries](/webhooks/overview#delivery-semantics): respond with
  any 2xx; the envelope carries an `event_id` to dedup on.
* **Blocking** exchanges run only on the
  [legacy single-URL webhook](/webhooks/overview): the
  [`telephony.incoming` / `web.incoming`](/webhooks/call-incoming)
  configuration request (webhook-mode numbers: 13 s including one
  retry; webhook-mode widget keys: 10 s) and webhook-mode
  [tool dispatch](/tools/overview#webhook-mode-dispatch). Your response
  shapes the live call.

Example payloads below show the endpoint envelope in its wire order
(keys sorted alphabetically: `data`, `event_id`, `type`); legacy
deliveries carry the same `data` without `event_id`.

## Call events

### `telephony.incoming`

Sent when an inbound call reaches one of your
[phone numbers](/api-reference/phone-numbers). Endpoint deliveries are
fire-and-forget notifications sent for **every** inbound call, whether
the number is agent-configured or webhook-configured. Numbers without
an assigned agent additionally receive the **blocking** configuration
request on the legacy webhook — see
[`telephony.incoming` / `web.incoming`](/webhooks/call-incoming) for
the full request / response schema.

```json
{
  "data": {
    "call_id": 987654321,
    "from_number": "+14155550199",
    "to_number": "+15551234567"
  },
  "event_id": "3f6b2ad0-1c9e-4a57-9f2b-8f6f0f9d2f11",
  "type": "telephony.incoming"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `agent_id` | integer \| null | Agent that handled the call, when one was assigned |
| `agent_name` | string \| null | Agent that handled the call, when one was assigned |

### `telephony.complete`

Sent when an inbound or outbound telephony call ends. Non-blocking.
Includes the transcript, recording URL when available, and billing summary. See
[`telephony.complete` / `web.complete`](/webhooks/call-complete) for
the payload schema.

| Field | Type | Description |
|-------|------|-------------|
| `agent_id` | integer \| null | Agent that handled the call, when one was assigned |
| `agent_name` | string \| null | Agent that handled the call, when one was assigned |

### `telephony.tool`

Sent after a telephony call invokes a
[function tool](/tools/overview). Non-blocking audit notification —
the tool has already executed when this event is delivered; it covers
your own function tools and the built-in `send_sms` tool (not other built-in,
knowledge-base, app-connection, or MCP tools). For `send_sms`,
both `arguments.to` and `arguments.body` are `[REDACTED]`. Completion
transcripts also redact them; retrieve the content and recipient from the
organization-scoped SMS resource using the response's `message_id`.

```json
{
  "data": {
    "arguments": { "date": "2026-04-21" },
    "call_id": 987654321,
    "from_number": "+14155550199",
    "response": {
      "response": { "available_slots": ["9:00 AM", "2:00 PM"] },
      "status": 200
    },
    "to_number": "+15551234567",
    "tool_name": "search_appointments"
  },
  "event_id": "1f0a7c3e-52d4-4a0e-8f4b-b1a6a1c0d9e2",
  "type": "telephony.tool"
}
```

`response` is the executed result: `{"status": <http status>,
"response": <your endpoint's JSON>}` on success, or
`{"status": <status>, "error": "<message>"}` on failure.

| Field | Type | Description |
|-------|------|-------------|
| `agent_id` | integer \| null | Agent that handled the call, when one was assigned |
| `agent_name` | string \| null | Agent that handled the call, when one was assigned |

### `telephony.turn`

Sent while a telephony call is **in progress**, once for each
speech-bearing turn as it happens — the agent's spoken completions and
the caller's transcribed turns. Lets you follow the live conversation
over plain webhooks instead of polling
[`GET /v1/calls/{call_id}/transcript`](/api-reference/calls#get-transcript).
Non-blocking.

<Note>
  **Explicit subscription required.** Unlike every other event type,
  turn events are never delivered through an empty `events` array
  (subscribe-to-all). An endpoint receives them only when its `events`
  list contains `telephony.turn` / `web.turn` explicitly.
</Note>

```json
{
  "data": {
    "call_id": 987654321,
    "entry_type": "completion",
    "from_number": "+14155550199",
    "position": 7,
    "role": "assistant",
    "start_ms": 15200,
    "text": "How many employees does your company have?",
    "to_number": "+15551234567"
  },
  "event_id": "8d3f5a2c-7b1e-4c9a-b6d0-2e4f6a8c0d1e",
  "type": "telephony.turn"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `agent_id` | integer \| null | Agent that handled the call, when one was assigned |
| `agent_name` | string \| null | Agent that handled the call, when one was assigned |
| `position` | integer | The turn's index in the call history — a stable identity for ordering |
| `role` | string | `assistant` (agent speech) or `user` (caller speech) |
| `text` | string | The turn's transcript text as known at emission time |
| `entry_type` | string | The underlying history entry type: `completion` (agent), or `user_turn` / `span` (caller) |
| `start_ms`, `end_ms` | integer | Audio offsets in ms since call start; present only when playout timing was already known at emission time |

<Note>
  Delivery is at-least-once and turns may arrive **out of order**:
  dedup on the envelope's `event_id` and order by `position`. Caller
  transcripts may still refine after emission; the post-call
  [`telephony.complete`](/webhooks/call-complete) transcript is
  authoritative. Only spoken turns emit — tool calls, whispers, and
  other internal entries never appear on this stream.
</Note>

### `web.incoming`

The web-channel equivalent of `telephony.incoming`, sent when a
[web widget](/widget/overview) session or a builder mic test call
starts. Endpoint deliveries are fire-and-forget for every web session.
Publishable keys in `mode="webhook"` additionally receive the
**blocking** configuration request on the legacy webhook — that
blocking request has a different shape (`origin_domain`,
`publishable_key_prefix`; no phone numbers). See
[`telephony.incoming` / `web.incoming`](/webhooks/call-incoming).

```json
{
  "data": {
    "call_id": 987654322,
    "from_number": "web",
    "origin_domain": "https://example.com",
    "publishable_key_prefix": "pk_live_a1b2",
    "to_number": "+15551234567"
  },
  "event_id": "9a2b4c6d-8e0f-4a1b-9c2d-3e4f5a6b7c8d",
  "type": "web.incoming"
}
```

`from_number` is always the literal `"web"`. For webhook-mode widget
sessions `to_number` is empty (the session's agent number is assigned
after configuration); for builder mic test calls `origin_domain` and
`publishable_key_prefix` are empty.

| Field | Type | Description |
|-------|------|-------------|
| `agent_id` | integer \| null | Agent that handled the call, when one was assigned |
| `agent_name` | string \| null | Agent that handled the call, when one was assigned |

### `web.complete`

The web-channel equivalent of `telephony.complete`, covering web
widget calls (`direction: "web"`) and builder mic test calls
(`direction: "test"`). Non-blocking. Same payload shape as
[`telephony.complete`](/webhooks/call-complete), plus `origin_domain`,
with `from_number` set to `"web"`.

| Field | Type | Description |
|-------|------|-------------|
| `agent_id` | integer \| null | Agent that handled the call, when one was assigned |
| `agent_name` | string \| null | Agent that handled the call, when one was assigned |

<Note>
  On the legacy single-URL webhook, builder mic test calls
  historically report as `telephony.complete` — only `direction:
  "web"` calls use the `web.complete` type there. The endpoint system
  maps both web and test calls to `web.*`. Historical payloads may
  contain the legacy `direction` values `widget` or `mic`.
</Note>

### `web.tool`

The web-channel equivalent of `telephony.tool`. The `data` carries
`origin_domain` instead of `from_number` / `to_number`.

| Field | Type | Description |
|-------|------|-------------|
| `agent_id` | integer \| null | Agent that handled the call, when one was assigned |
| `agent_name` | string \| null | Agent that handled the call, when one was assigned |

### `web.turn`

The web-channel equivalent of [`telephony.turn`](#telephony-turn),
covering web widget calls and builder mic test calls. Same payload
shape, with `origin_domain` instead of `from_number` / `to_number`.
Like `telephony.turn`, it requires an **explicit subscription** — it
is never delivered through an empty `events` array.

| Field | Type | Description |
|-------|------|-------------|
| `agent_id` | integer \| null | Agent that handled the call, when one was assigned |
| `agent_name` | string \| null | Agent that handled the call, when one was assigned |

### `call.transfer_completed`

Sent once per call with the final outcome of a
[call transfer](/guides/call-transfers): a cold transfer, or the human
bridge a warm transfer leaves behind after the agent drops out.
Non-blocking, and delivered to endpoints only (never to the legacy
single-URL webhook). It arrives after the call's `telephony.complete`:
once the carrier takes the call or the transfer fails, or, when
ThunderPhone bridged the call, once the bridge ends.

```json
{
  "data": {
    "agent_id": 12,
    "agent_name": "Front desk",
    "bridge_kind": "cold_fallback",
    "bridge_seconds": 255,
    "bridge_total_cents": 5,
    "call_id": 987654321,
    "destination": "+15555550102",
    "sip_status": 403,
    "transfer_outcome": "bridged"
  },
  "event_id": "5b0f6c1e-9d2a-5e7b-8c4d-1a2b3c4d5e6f",
  "type": "call.transfer_completed"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `call_id` | integer | The call that was transferred |
| `agent_id` | integer \| null | Agent that handled the call, when one was assigned |
| `agent_name` | string \| null | Agent that handled the call, when one was assigned |
| `transfer_outcome` | string | `refer_accepted`: the carrier took the call over. `bridged`: ThunderPhone kept the caller and the destination connected itself, either because the carrier refused a cold transfer or because a warm transfer's agent dropped out. `bridge_unanswered`: the carrier refused a cold transfer and the destination did not answer ThunderPhone's own call. `failed`: the transfer did not go through and the call ended |
| `destination` | string \| null | The number the call was transferred to |
| `sip_status` | integer \| null | The carrier's SIP status when it refused the cold transfer: `403` (for example a Twilio trunk without call transfer), `405` or `501`. `null` when the carrier did not refuse it |
| `bridge_kind` | string \| null | `bridged` only: `cold_fallback` (a refused cold transfer ThunderPhone connected) or `warm` (a warm transfer's two people after the agent left) |
| `bridge_seconds` | integer \| null | `bridged` only: the bridged time ThunderPhone billed, in seconds |
| `bridge_total_cents` | integer \| null | `bridged` only: what the bridge cost in USD cents after any free call credit, billed separately from the call's `billing_total_cents` |

The same values are on the [call object](/api-reference/calls#call-object)
as `transfer_outcome`, `transfer_destination`, `transfer_sip_status`,
`transfer_bridge_kind`, `transfer_bridge_seconds` and
`transfer_bridge_total_cents`. `event_id` is the same on every delivery
for a call, so a repeated report never produces a second event.

---

## SMS events

SMS events describe messages sent through the organization's connected
Twilio or Telnyx account. The message body is omitted and `to` is redacted.
Retrieve the organization-scoped message from
[`GET /v1/sms/{message_id}`](/api-reference/sms#retrieve-a-message) when its
content or recipient is needed.

### `sms.sent`

Sent when the customer's carrier accepts an outbound SMS request. This is not
a handset delivery receipt.

```json
{
  "data": {
    "accepted_at": "2026-09-19T15:42:10.113Z",
    "agent_id": 12,
    "call_id": 987654321,
    "created_at": "2026-09-19T15:42:09.901Z",
    "direction": "outbound",
    "error_code": null,
    "error_message": null,
    "from_number": "+15551234567",
    "id": "313a3117-827b-46e9-a01a-ac1493838724",
    "provider": "twilio",
    "provider_id": "SM0123456789abcdef0123456789abcdef",
    "segment_count": 1,
    "source": "agent_tool",
    "status": "accepted",
    "to": "[REDACTED]"
  },
  "event_id": "0ae347b4-1716-45bb-9a0e-b52373586e37",
  "type": "sms.sent"
}
```

### `sms.failed`

Sent after a permanent carrier rejection, after transient retries are
exhausted, or when the outcome is terminally unknown. `error_code` is a stable
ThunderPhone code and `error_message` states the customer action when one is
available. `status: "unknown"` means the carrier may have accepted the SMS;
reconcile it in the carrier log because ThunderPhone will not resend it
automatically.

### `sms.received`

Reserved for inbound SMS support. No `sms.received` events are emitted in the
current release. Inbound carrier webhook configuration and signature
verification will ship separately.

---

## Voice events

Custom voice creation is asynchronous. These non-blocking events let you
react to a terminal result instead of polling the
[clone detail endpoint](/api-reference/custom-voices#list-and-retrieve-clones).

`voice.ready` and `voice.failed` are delivered only to an organization-wide
endpoint with `events: []`. They cannot be selected as explicit event filters.

### `voice.ready`

Sent when a custom voice finishes processing and can be assigned to an
agent.

```json
{
  "data": {
    "voice": {
      "created_at": "2026-07-30T14:12:08.317Z",
      "display_name": "Support voice",
      "failure_reason": "",
      "gender": "female",
      "id": "cv_2f6f90b0e9a34ee8b39be7d1",
      "language": "en",
      "name": "custom:cv_2f6f90b0e9a34ee8b39be7d1",
      "status": "ready",
      "updated_at": "2026-07-30T14:13:31.605Z"
    }
  },
  "event_id": "2d5f0a61-e9b5-4a3c-b684-29d7d9e4b214",
  "type": "voice.ready"
}
```

### `voice.failed`

Sent when custom voice processing reaches a permanent failure.

```json
{
  "data": {
    "reason": "audio sample could not be processed",
    "voice": {
      "created_at": "2026-07-30T14:12:08.317Z",
      "display_name": "Support voice",
      "failure_reason": "audio sample could not be processed",
      "gender": "female",
      "id": "cv_2f6f90b0e9a34ee8b39be7d1",
      "language": "en",
      "name": "custom:cv_2f6f90b0e9a34ee8b39be7d1",
      "status": "failed",
      "updated_at": "2026-07-30T14:13:31.605Z"
    }
  },
  "event_id": "3493e985-1a75-4f77-a10a-e74af440cd31",
  "type": "voice.failed"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `voice.id` | string | Custom voice public id |
| `voice.name` | string | Agent voice value in the form `custom:<public_id>` |
| `voice.display_name` | string | Organization-facing voice name |
| `voice.language` | string | The clone's single language code |
| `voice.gender` | string | `male`, `female`, or an empty string |
| `voice.status` | string | `ready` for `voice.ready`; `failed` for `voice.failed` |
| `voice.failure_reason` | string | Empty on success; processing failure detail on failure |
| `voice.created_at`, `voice.updated_at` | timestamp | ISO 8601 timestamps |
| `reason` | string | Failure detail; present only on `voice.failed` |

---

## Quality events

### `call.graded`

Sent whenever an [AI grading run](/api-reference/calls#ai-call-grading)
completes for a call. Non-blocking.

```json
{
  "data": {
    "call_id": 987654321,
    "grade": {
      "call_outcome": "success",
      "created_at": "2026-04-20T18:25:11.002Z",
      "detected_issues": [],
      "graded_at": "2026-04-20T18:25:11.002Z",
      "grader_model": "heuristic-v1",
      "id": 5512,
      "score": 92,
      "status": "completed",
      "summary": "Caller asked about their policy and got a full answer…"
    },
    "report": null
  },
  "event_id": "7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f",
  "type": "call.graded"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `agent_id` | integer \| null | Agent that handled the call, when one was assigned |
| `agent_name` | string \| null | Agent that handled the call, when one was assigned |
| `grade.id` | integer | Grade id |
| `grade.score` | integer \| null | 0–100 |
| `grade.call_outcome` | string | `success`, `failure`, `unknown`, or `no_conversation` |
| `grade.summary` | string | One-paragraph summary |
| `grade.detected_issues` | array | Issue strings found by the grader |
| `grade.status` | string | Always `completed` — only finished runs emit |
| `grade.grader_model` | string | Which grader produced the result (e.g. `heuristic-v1`) |
| `grade.graded_at`, `grade.created_at` | timestamp | |
| `report` | object \| null | The customer-facing [call report](/api-reference/calls#call-report): outcome label, needs-attention flag and reason, caller, one-line summary, caller experience. On an early heuristic grade it is `null` or a `pending` report whose fields are placeholders; the full grade's event carries the completed one |

<Note>
  A call can be graded more than once — a fast heuristic grade is
  often followed by a full model grade once the recording is
  available, and manual regrades are possible. Each completed run
  emits its own `call.graded` event; treat the latest `graded_at` as
  authoritative.
</Note>

### `call.data_extracted`

Sent whenever structured data extraction completes successfully, including a
late retry after `telephony.complete` / `web.complete` or a manual re-run via
[`POST /v1/calls/{call_id}/extract`](/api-reference/calls#run-data-extraction).
Non-blocking.

In a blocking extraction mode, the completion event normally waits no more
than the 75-second extraction budget. If the extraction worker process is
lost, the production finalization drain (every five minutes) releases a
completion whose `blocking_deadline_at` has passed before it starts another
extraction attempt. A later success is delivered separately as this event.

<Warning>
  Extracted values are a model's interpretation of the conversation. Each
  non-null value includes the quote, speaker role, and turn index it was based
  on, plus a run-level verification flag. Verify the result before decisions
  involving money, identity, or access.
</Warning>

```json
{
  "data": {
    "call_id": 987654321,
    "agent_id": 12,
    "agent_name": "Acme intake",
    "extracted_data": {
      "status": "completed",
      "fields": {
        "customer_name": "Alex Morgan",
        "appointment_date": "2026-04-23"
      },
      "evidence": {
        "customer_name": {
          "quote": "My name is Alex Morgan",
          "speaker_role": "caller",
          "turn_index": 4
        },
        "appointment_date": {
          "quote": "April 23 works for me",
          "speaker_role": "caller",
          "turn_index": 7
        }
      },
      "verification": "verified",
      "field_reasons": {},
      "schema_version": "92850758e231a3c95a..."
    },
    "extracted_at": "2026-04-20T18:25:11.002Z",
    "model": "<model id>",
    "summary": "Alex Morgan called to book a follow-up visit and confirmed Thursday, April 23."
  },
  "event_id": "4d79ef1d-c2b1-4ed6-85b8-8326bd2895ef",
  "type": "call.data_extracted"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `agent_id` | integer \| null | Agent that handled the call, when one was assigned |
| `agent_name` | string \| null | Agent that handled the call, when one was assigned |
| `summary` | string \| null | The completed [call summary](/webhooks/call-complete) when the agent has **Generate a call summary** on, so a late extraction event also delivers a summary that missed the completion window; `null` otherwise. Agents with a summary but no extraction fields never emit this event: read a late summary from `GET /v1/calls/{call_id}` |
| `extracted_data.status` | string | Always `completed` for this event |
| `extracted_data.fields` | object | Values keyed by the configured extraction field keys; unavailable values are `null` |
| `extracted_data.evidence` | object | Evidence keyed by extraction field. A non-null value has the exact structurally checked quote (at most 1,000 characters), `speaker_role` (`caller` or `agent`), and `turn_index`; longer model-returned quotes are rejected instead of truncated, and evidence is `null` whenever its field is `null` |
| `extracted_data.verification` | string | `verified` only when the independent evidence pass returned exactly one valid verdict for every candidate field. `unavailable` means the pass failed, timed out, lacked enough budget, or returned malformed or partial output. A wholly unavailable pass preserves structurally grounded values for customer review. For partial output, valid verdicts are applied and each candidate without exactly one valid verdict is nulled |
| `extracted_data.field_reasons` | object | Reasons keyed by fields that were nulled by structural grounding or the independent verifier |
| `extracted_data.schema_version` | string | Hash of the exact field schema used for this extraction |
| `extracted_at` | timestamp | ISO 8601 completion time |
| `model` | string | Model used for the extraction |

### `campaign.completed`

Sent once when a campaign moves from `running` to `completed`, whether its
schedule ended or all contacts reached terminal states. Runner retries do not
emit another event. This organization-level lifecycle event is delivered only
to organization-scoped endpoints, not agent-scoped endpoints. Non-blocking.

```json
{
  "data": {
    "campaign_id": "3f6b2c9e-2a0d-4c63-b6d6-a708dc98f403",
    "name": "May win-back",
    "agent_id": 12,
    "status": "completed",
    "started_at": "2026-04-20T17:00:00Z",
    "completed_at": "2026-04-20T18:25:11Z",
    "counts": {
      "contacts_total": 150,
      "completed": 121,
      "failed": 11,
      "no_answer": 18,
      "remaining": 0
    }
  },
  "event_id": "8d8f52ce-6b46-423f-9dde-cea0b91ec135",
  "type": "campaign.completed"
}
```

The four outcome counts are disjoint and add up to `contacts_total`:
`completed` contains successful contacts; `no_answer` contains terminal
failed/exhausted contacts whose final outcome was no answer; `failed` contains
all other terminal failed/exhausted contacts; and `remaining` contains pending,
scheduled, or currently calling contacts. A contact waiting for a retry is
`remaining`, even when its most recent attempt was no answer. In-flight calls
are reconciled before the once-only completion snapshot. `started_at` is the
configured campaign start, or the campaign creation time when no start was
configured.

### `issue.reported`

Sent when an [issue report](/api-reference/issue-reports) is created —
either filed by a user from the dashboard (`source: "user"`) or
automatically by call grading (`source: "system"`). Non-blocking.

```json
{
  "data": {
    "call_id": 987654321,
    "issue_report": {
      "created_at": "2026-04-20T18:25:11.002Z",
      "description": "Five-second silence before responding to the main question.",
      "id": 4321,
      "severity": "warning",
      "source": "system",
      "status": "open",
      "title": "Agent paused too long"
    }
  },
  "event_id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
  "type": "issue.reported"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `agent_id` | integer \| null | Agent that handled the call, when one was assigned |
| `agent_name` | string \| null | Agent that handled the call, when one was assigned |
| `issue_report.severity` | string | `critical`, `warning`, or `info` |
| `issue_report.status` | string | `open` or `resolved` |
| `issue_report.source` | string | `user` (filed from the dashboard) or `system` (created by grading) |

<Note>
  Regrading a call rebuilds its system-generated issue reports, which
  re-emits `issue.reported` for the recreated reports. Dedup on
  `call_id` + `title` if you only want one notification per underlying
  problem.
</Note>

### `issue.escalated`

Sent when an issue pattern is sent to ThunderPhone for staff review: after
**Report to ThunderPhone**, or when Fix with AI cannot confirm a customer-side
fix and routes the issue automatically. Only organization-wide endpoints
receive this event.

```json
{
  "data": {
    "automatic": false,
    "cluster_id": "7ac2844c-2df0-4fa8-a560-7378da649e19",
    "escalation_id": "ec99f52b-c8c0-41dd-a4f2-dd8a07b10894",
    "status": "open"
  },
  "event_id": "d8f8f420-7a42-45ba-bcf1-b747f9bbecda",
  "type": "issue.escalated"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `automatic` | boolean | `true` for an automatic escalation; `false` for a manual escalation |
| `cluster_id` | UUID | Escalated issue pattern |
| `escalation_id` | UUID | Escalation record |
| `status` | string | `open` when the event is emitted |

This is a notification, not the evidence bundle. Use `cluster_id` to correlate
it with the issue pattern. See [Report to ThunderPhone](/guides/issues#report-to-thunderphone).

---

## Test call events

### `test-call.completed`

Sent when a
[test-call run](/api-reference/test-calls#test-call-run-object)
reaches a terminal status — `completed` or `failed`, including runs
that failed at launch and never produced a call. Non-blocking. Useful
for wiring batch CI runs into your chat/notifications systems.

```json
{
  "data": {
    "test_call_run": {
      "call_id": 987654321,
      "completed_at": "2026-04-20T18:25:04.822Z",
      "error_message": "",
      "id": 7110,
      "status": "completed",
      "target_id": 12,
      "target_type": "agent"
    }
  },
  "event_id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
  "type": "test-call.completed"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `agent_id` | integer \| null | Agent that handled the call, when one was assigned |
| `agent_name` | string \| null | Agent that handled the call, when one was assigned |
| `test_call_run.target_type` | string | `agent` or `phone_number` |
| `test_call_run.target_id` | integer | The agent id or phone-number id the run targeted, matching `target_type` |
| `test_call_run.status` | string | `completed` or `failed` |
| `test_call_run.call_id` | integer \| null | `null` when the run failed before a call was placed |
| `test_call_run.error_message` | string | Empty on success |

---

## Alert events

### `alert.triggered`

Sent when an [alert rule](/guides/alerts) with the **Deliver to
developer webhooks** channel enabled crosses its threshold.
Non-blocking. A rule fires once and then respects its cooldown, so a
sustained breach produces one event per cooldown window.

```json
{
  "data": {
    "comparator": "lt",
    "event_id": "b8e6a1d4-2c3f-4a5b-9c8d-7e6f5a4b3c2d",
    "fired_at": "2026-04-20T18:00:00+00:00",
    "metric": "success_rate",
    "metric_value": 71.4,
    "rule_id": "d2c3b4a5-6f7e-4d8c-9b0a-1c2d3e4f5a6b",
    "rule_name": "Success rate below 80%",
    "threshold": 80.0,
    "window_hours": 24
  },
  "event_id": "4d5e6f7a-8b9c-4d0e-9f1a-2b3c4d5e6f7a",
  "type": "alert.triggered"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `event_id` (in `data`) | UUID | The alert **firing** id — distinct from the envelope's delivery `event_id` |
| `rule_id`, `rule_name` | UUID, string | The rule that fired |
| `metric` | string | `success_rate`, `failure_rate`, `avg_score`, `call_volume`, or `suite_regression` |
| `comparator` | string | `lt`, `lte`, `gt`, or `gte` |
| `metric_value` | number | The metric's value over the window when the rule fired |
| `threshold` | number | The configured threshold |
| `window_hours` | integer | Trailing evaluation window |
| `fired_at` | timestamp | |

See the [Alerts guide](/guides/alerts) for creating rules, metrics,
cooldowns, and the email / Slack channels.

---

## Migration events

### `migration.call.complete`

Sent when a call placed through
[`POST /v1/migrations/{id}/calls`](/api-reference/migration-calls) ends,
whichever platform took it. Non-blocking. It arrives up to about 5 minutes
after the call ends. `data` has every field of
[`telephony.complete`](/webhooks/call-complete) plus a `migration` block. A
call ThunderPhone placed also sends `telephony.complete` as usual, with the
same `call_id`. A call on the other platform that has not ended after 2
hours, or whose migration finished or lost its key first, is sent with
`status: "unknown"` and no transcript.

```json
{
  "data": {
    "agent_id": 42,
    "agent_name": "Front desk",
    "billable_minutes": null,
    "billing_total_cents": null,
    "call_id": null,
    "direction": "outbound",
    "duration_seconds": 94,
    "end_reason": "user_hangup",
    "end_time": "2026-10-06T17:01:34+00:00",
    "extracted_data": null,
    "from_number": "+15551234567",
    "migration": {
      "arm": "vendor",
      "id": "5f0c2a9e-7b1d-4c3e-8a6f-2d9e1b4c7a30",
      "metadata": {
        "lead_id": "8812"
      },
      "routing_id": "9b2f8c1e-4d3a-4f6b-8e2d-1a7c5b9e0f34",
      "vendor": "retell",
      "vendor_call_id": "call_6a1f0e",
      "vendor_cost_usd": "0.1240"
    },
    "product": "",
    "recording_mode": "vendor",
    "recording_url": "https://…",
    "start_time": "2026-10-06T17:00:00+00:00",
    "status": "completed",
    "summary": "The callee booked a table for two on Thursday.",
    "thunderphone_retained_call_content": true,
    "to_number": "+14155552671",
    "transcripts": [
      {
        "content": "Hi Ada, this is Rosa's calling about your booking.",
        "role": "agent"
      },
      {
        "content": "Yes, Thursday works.",
        "role": "user"
      }
    ],
    "transfer_number": null,
    "unresolved_variables": [],
    "variables": {
      "first_name": "Ada"
    },
    "voice": ""
  },
  "event_id": "7c1e2f3a-4b5c-4d6e-8f7a-9b0c1d2e3f4a",
  "type": "migration.call.complete"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `migration.id` | UUID | The migration |
| `migration.routing_id` | UUID | The routed call, as returned when you placed it |
| `migration.arm` | string | `ours` (ThunderPhone placed it) or `vendor` (the other platform did) |
| `migration.vendor` | string | `retell`, `vapi`, `elevenlabs`, or `bland` |
| `migration.vendor_call_id` | string \| null | The other platform's call id; `null` for `ours` |
| `migration.metadata` | object | The `metadata` you sent when placing the call |
| `migration.vendor_cost_usd` | string \| null | What the other platform reported the call cost, when it did |
| `call_id` | integer \| null | The ThunderPhone call id; `null` for `vendor` |
| `status` | string | For `vendor`: `completed` when the callee spoke, `failed` when they did not, `unknown` as above. For `ours`: as in `telephony.complete` |

For a `vendor` call, ThunderPhone has no billing or extraction data:
`billable_minutes`, `billing_total_cents` and `extracted_data` are `null`,
`product` and `voice` are empty, `recording_mode` is `vendor`, and
`recording_url` is the other platform's link, when it gave one.

### `migration.rollback`

Sent when a migration's gradual switch moves every call back to the other
platform on its own: 3 calls failed to connect to ThunderPhone within 15
minutes, 2 calls sent to ThunderPhone ended before the agent answered, or
ThunderPhone's task outcome rate fell more than the configured gap below the
other platform's. Non-blocking, once per rollback. After it, ThunderPhone's
share is 0% and the migration is paused until someone resumes it on the
agent's Migration tab. The payload carries no phone numbers, transcripts or
call ids.

```json
{
  "data": {
    "agent_id": 42,
    "agent_name": "Front desk",
    "dropped": 0,
    "migration_id": "5f0c2a9e-7b1d-4c3e-8a6f-2d9e1b4c7a30",
    "ours": {
      "graded": 20,
      "success_rate": 0.8
    },
    "ours_failed": 3,
    "ours_percent": 0,
    "previous_percent": 25,
    "reason": "ours_failed_burst",
    "rolled_back_at": "2026-10-10T12:05:00+00:00",
    "step": 1,
    "theirs": {
      "graded": 20,
      "success_rate": 0.8
    },
    "vendor": "retell"
  },
  "event_id": "3d1f6b2a-8c4e-5a7b-9d0e-2f1a3b4c5d6e",
  "type": "migration.rollback"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `migration_id` | UUID | The migration |
| `vendor` | string | `retell`, `vapi`, `elevenlabs`, or `bland` |
| `reason` | string | `ours_failed_burst` (3 failed connections in 15 minutes), `dropped_calls` (2 calls ended before the agent answered, within 30 minutes), or `rate_gap` (task outcome rate gap over at least `rollback_min_calls` graded calls per side) |
| `previous_percent` | integer | ThunderPhone's share of calls before the rollback |
| `ours_percent` | integer | Always `0` |
| `step` | integer \| null | The schedule step it was on (0-based) |
| `ours` / `theirs` | object \| null | Graded calls in the current step and their success rate; `theirs` may be the last 30 days when the step has too few calls. `null` when there were none |
| `ours_failed` | integer | Failed connections counted in the last 15 minutes |
| `dropped` | integer | Calls counted as dropped in the last 30 minutes |
| `rolled_back_at` | timestamp | |

---

## Related

<CardGroup cols={2}>
  <Card title="telephony.incoming / web.incoming" icon="phone" href="/webhooks/call-incoming">
    The blocking inbound-call payload you must respond to.
  </Card>
  <Card title="telephony.complete / web.complete" icon="phone-slash" href="/webhooks/call-complete">
    Post-call transcript and metrics.
  </Card>
  <Card title="Webhook endpoints" icon="bolt" href="/webhooks/endpoints">
    Subscribe a URL to a subset of these events.
  </Card>
  <Card title="Function Tools" icon="screwdriver-wrench" href="/tools/overview">
    How `telephony.tool` / `web.tool` events are generated.
  </Card>
</CardGroup>
