> ## Documentation Index
> Fetch the complete documentation index at: https://docs.thunderphone.com/llms.txt
> Use this file to discover all available pages before exploring further.

# telephony.complete / web.complete

> Webhook không chặn được gửi khi cuộc gọi kết thúc, kèm bản ghi lời thoại, URL bản ghi âm và chỉ số.

Sự kiện hoàn tất được kích hoạt sau khi mỗi cuộc gọi kết thúc — điện thoại đến,
điện thoại đi, cuộc gọi web hoặc cuộc gọi thử nghiệm (phiên mic trong builder). Sự kiện này
**không chặn**: phản hồi bằng bất kỳ mã 2xx nào.

Sự kiện được gửi qua cả hai đường dẫn:

* **[Điểm cuối webhook](/vi/webhooks/endpoints)** nhận
  `telephony.complete` (cuộc gọi điện thoại) hoặc `web.complete` (cuộc gọi web và
  cuộc gọi thử nghiệm mic trong builder) với payload ổn định được mô tả bên dưới,
  `event_id` cho mỗi lần gửi, thời gian chờ 30 giây và
  [thử lại trong tối đa 24 giờ](/vi/webhooks/overview).
* **[Webhook URL đơn cũ](/api-reference/organizations#legacy-single-url-webhook)**
  nhận một lần thử đồng bộ (thời gian chờ 10 giây, không thử lại) với
  payload hơi khác — xem
  [Khác biệt của payload cũ](#legacy-payload-differences).

## Payload yêu cầu (gửi đến điểm cuối)

```json theme={null}
{
  "data": {
    "billable_minutes": 1.25,
    "billing_total_cents": 8,
    "call_id": 987654321,
    "direction": "inbound",
    "duration_seconds": 54,
    "end_reason": "user_hangup",
    "end_time": "2026-04-20T18:25:04.822Z",
    "from_number": "+14155550199",
    "product": "spark",
    "recording_url": "https://storage.example.com/…",
    "start_time": "2026-04-20T18:24:10.113Z",
    "status": "completed",
    "to_number": "+15551234567",
    "transcripts": [ /* see Transcript format */ ],
    "transfer_number": null,
    "voice": "john"
  },
  "event_id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
  "type": "telephony.complete"
}
```

| Trường                     | Kiểu            | Mô tả                                                                                                                                           |
| -------------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `call_id`                  | integer         | Ổn định trên mọi sự kiện của cuộc gọi này                                                                                                       |
| `direction`                | string          | `inbound`, `outbound`, `web`, `test`. Payload lịch sử có thể chứa các giá trị cũ `mic` hoặc `widget`                                            |
| `from_number`, `to_number` | string          | E.164. `from_number` là giá trị `"web"` theo nghĩa đen cho cuộc gọi web và cuộc gọi thử nghiệm                                                  |
| `origin_domain`            | string          | **Chỉ web/thử nghiệm** — origin của trang lưu trữ widget (trống đối với phiên mic)                                                              |
| `start_time`, `end_time`   | timestamp       | ISO 8601 UTC                                                                                                                                    |
| `duration_seconds`         | integer \| null | Được suy ra từ thời điểm bắt đầu/kết thúc                                                                                                       |
| `status`                   | string          | `completed` hoặc `failed`                                                                                                                       |
| `end_reason`               | string          | Xem bảng bên dưới                                                                                                                               |
| `product`, `voice`         | string          | Cấu hình tác nhân có hiệu lực tại thời điểm cuộc gọi                                                                                            |
| `transfer_number`          | string \| null  | Được đặt khi cuộc gọi được chuyển                                                                                                               |
| `recording_url`            | string \| null  | URL đã ký có thời hạn; hãy tải xuống sớm. `null` cho các phiên không có bản ghi                                                                 |
| `billable_minutes`         | number          | Số phút được tính phí, làm tròn đến 1/4 phút gần nhất (mỗi bước 15 giây, tối thiểu 0.25). Cuộc gọi đi thẳng vào thư thoại được tính cố định 1¢. |
| `billing_total_cents`      | integer         | Cent USD                                                                                                                                        |
| `transcripts`              | array           | Bản chép lời theo từng lượt — xem phần tiếp theo                                                                                                |

### Lý do kết thúc

| Giá trị            | Ý nghĩa                                                                     |
| ------------------ | --------------------------------------------------------------------------- |
| `user_hangup`      | Bên từ xa ngắt máy trước                                                    |
| `ai_hangup`        | AI chủ động kết thúc cuộc gọi                                               |
| `ai_transfer`      | AI đã chuyển cuộc gọi; `transfer_number` được đặt                           |
| `ai_warm_transfer` | AI đã hoàn tất chuyển cuộc gọi có tư vấn                                    |
| `voicemail_hangup` | Đã phát hiện thư thoại và cuộc gọi kết thúc theo `voicemail_action` của bạn |
| `max_duration`     | Cuộc gọi đạt giới hạn thời lượng tối đa                                     |
| `superseded`       | Phiên đã được thay thế bằng một phiên mới hơn                               |
| `unknown`          | Không thể xác định lý do kết thúc                                           |

## Định dạng bản ghi

Mỗi mục trong `transcripts` là một lượt hội thoại. Các vai trò gồm
`user` (lời nói của người gọi), `model` (lời nói của tác nhân AI **và** lệnh gọi công cụ),
`tool` (kết quả công cụ) và `system` (sự kiện cuộc gọi như chuyển đổi
ngôn ngữ).

```json theme={null}
[
  {
    "role": "user",
    "content_type": "text/plain",
    "content": "Hi, I'm calling about my appointment.",
    "start_ms": 1200,
    "end_ms":   4100,
    "audio_url": "https://storage.example.com/…"
  },
  {
    "role": "model",
    "content_type": "text/plain",
    "content": "Sure, what date works best?",
    "start_ms": 4200,
    "end_ms":   6100
  },
  {
    "role": "model",
    "content_type": "application/json",
    "content": {
      "tool_call": "search_appointments",
      "arguments": { "date": "2026-04-21" }
    }
  },
  {
    "role": "tool",
    "content_type": "application/json",
    "content": {
      "tool_name": "search_appointments",
      "response": { "available_slots": ["9:00 AM", "2:00 PM"] }
    }
  }
]
```

| Trường                    | Loại             | Mô tả                                                                                                                                                                 |
| ------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `role`                    | string           | `user`, `model`, `tool` hoặc `system`                                                                                                                                 |
| `content_type`            | string           | `text/plain` cho lời nói; `application/json` cho lệnh gọi công cụ, kết quả công cụ và sự kiện hệ thống                                                                |
| `content`                 | string \| object | Văn bản lời nói hoặc đối tượng có cấu trúc ở trên. Lệnh gọi công cụ: `{"tool_call": name, "arguments": {…}}`. Kết quả công cụ: `{"tool_name": name, "response": {…}}` |
| `start_ms`, `end_ms`      | integer          | Độ lệch tính từ khi cuộc gọi bắt đầu, tính bằng ms. Có mặt khi biết thời gian audio                                                                                   |
| `ttfa_ms`                 | integer          | Thời gian đến audio đầu tiên cho một lượt `model`, khi được đo                                                                                                        |
| `audio_url`, `audio_urls` | string / array   | URL đã ký có thời hạn cho audio của lượt, khi audio được ghi theo từng lượt                                                                                           |

Để xem lịch sử lượt đầy đủ có cấu trúc (bao gồm điểm đánh dấu ngắt,
ack-prompt và vị trí thô), hãy dùng
[`GET /v1/calls/{call_id}/history`](/api-reference/calls#get-history).

## Khác biệt của payload cũ

Envelope webhook cũ dùng một URL là
`{"type": "telephony.complete" | "web.complete", "data": {…}}`, không có
**`event_id`**, và `data` của nó khác với payload endpoint:

* Mảng lượt nằm trong **`history`**, không phải `transcripts` (cùng schema lượt
  như trên).
* Tập trường là báo cáo thô khi kết thúc cuộc gọi và có thể bao gồm
  các trường nội bộ bổ sung ngoài bảng trên — hãy coi các trường không xác định
  là thông tin tham khảo.
* Cuộc gọi web (`direction: "web"`) **bỏ qua** `from_number` / `to_number`
  và thêm `origin_domain`.
* Cuộc gọi kiểm tra mic trong Builder được báo cáo là `telephony.complete` trên
  đường dẫn cũ (hệ thống endpoint ánh xạ chúng thành `web.complete`).
* **Điều phối chuyển cuộc gọi:** khi một cuộc gọi kết thúc bằng việc chuyển cuộc gọi,
  webhook cũ được gọi đồng bộ và có thể trả về
  `{"transfer_ready": false}` để báo hiệu mục tiêu nhận chuyển cuộc gọi chưa
  sẵn sàng. Bất kỳ phản hồi nào khác (hoặc không có webhook cũ) đều cho phép việc chuyển cuộc gọi
  tiếp tục. Các lần phân phối endpoint không bao giờ được tham vấn cho việc này.

***

## Ví dụ về handler

<CodeGroup>
  ```python Python (FastAPI) theme={null}
  import hashlib
  import hmac
  import json
  import os

  from fastapi import FastAPI, HTTPException, Request

  app = FastAPI()
  SECRET = os.environ["THUNDERPHONE_WEBHOOK_SECRET"]

  def verify(body: bytes, signature: str) -> bool:
      expected = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, signature or "")

  @app.post("/thunderphone-webhook")
  async def webhook(request: Request):
      body = await request.body()
      if not verify(body, request.headers.get("X-ThunderPhone-Signature", "")):
          raise HTTPException(status_code=401)

      event = json.loads(body)
      if event["type"] in ("telephony.complete", "web.complete"):
          data = event["data"]
          # Endpoint deliveries use "transcripts"; the legacy webhook uses "history".
          turns = data.get("transcripts") or data.get("history") or []
          await persist_call_record(
              call_id=data["call_id"],
              turns=turns,
              recording_url=data.get("recording_url"),
          )
          if data["end_reason"] in ("ai_transfer", "ai_warm_transfer"):
              await notify_team(data.get("transfer_number"), data["call_id"])
      return {"ok": True}
  ```

  ```javascript Node.js (Express) theme={null}
  import crypto from "node:crypto";
  import express from "express";

  const app = express();
  const SECRET = process.env.THUNDERPHONE_WEBHOOK_SECRET;

  function verify(body, signature) {
    const expected = crypto.createHmac("sha256", SECRET).update(body).digest("hex");
    return signature &&
      crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
  }

  app.post(
    "/thunderphone-webhook",
    express.raw({ type: "application/json" }),
    async (req, res) => {
      if (!verify(req.body, req.header("X-ThunderPhone-Signature"))) {
        return res.sendStatus(401);
      }
      const event = JSON.parse(req.body.toString("utf8"));
      if (["telephony.complete", "web.complete"].includes(event.type)) {
        const data = event.data;
        // Endpoint deliveries use "transcripts"; the legacy webhook uses "history".
        const turns = data.transcripts ?? data.history ?? [];
        await persistCallRecord({ ...data, turns });
        if (["ai_transfer", "ai_warm_transfer"].includes(data.end_reason)) {
          await notifyTeam(data.transfer_number, data.call_id);
        }
      }
      res.json({ ok: true });
    },
  );
  ```
</CodeGroup>

***

## Trường hợp sử dụng phổ biến

<CardGroup cols={2}>
  <Card title="Tích hợp CRM" icon="database">
    Lưu transcript và URL bản ghi của từng cuộc gọi cùng với hồ sơ khách hàng của bạn.
  </Card>

  <Card title="Phân tích" icon="chart-line">
    Truyền transcript vào pipeline để lập mô hình chủ đề, trích xuất tín hiệu CSAT hoặc theo dõi tỷ lệ chuyển cuộc gọi.
  </Card>

  <Card title="Đánh giá chất lượng" icon="clipboard-check">
    Mở cuộc gọi trong công cụ QA để con người đánh giá hoặc chạy qua mô hình đánh giá riêng của bạn.
  </Card>

  <Card title="Thông báo" icon="bell">
    Kích hoạt một thành viên trong nhóm khi chuyển cuộc gọi / xảy ra lỗi.
  </Card>
</CardGroup>

***

## Liên quan

<CardGroup cols={2}>
  <Card title="telephony.incoming / web.incoming" icon="phone" href="/vi/webhooks/call-incoming">
    Đối tác chặn chạy khi cuộc gọi bắt đầu.
  </Card>

  <Card title="Danh mục sự kiện" icon="list" href="/vi/webhooks/events">
    Các loại sự kiện khác mà bạn có thể đăng ký.
  </Card>

  <Card title="API lịch sử cuộc gọi" icon="phone" href="/api-reference/calls">
    Dữ liệu tương tự có thể truy cập qua REST để bổ sung dữ liệu / phát lại.
  </Card>
</CardGroup>
