---
title: "telephony.complete / web.complete"
description: "Webhook non-pemblokiran yang dikirimkan saat panggilan berakhir, dengan transkrip, URL rekaman, dan metrik."
---

Peristiwa completion dipicu setelah setiap panggilan berakhir — telepon masuk,
telepon keluar, panggilan web, atau panggilan uji (sesi mikrofon builder). Peristiwa ini
**non-blocking**: respons dengan 2xx apa pun.

Peristiwa dikirimkan melalui kedua jalur:

* **[Endpoint webhook](/id/webhooks/endpoints)** menerima
  `telephony.complete` (panggilan telepon) atau `web.complete` (panggilan web dan
  panggilan uji mikrofon builder) dengan payload stabil yang didokumentasikan di bawah,
  `event_id` per pengiriman, batas waktu 30 dtk, dan
  [percobaan ulang hingga 24 jam](/id/webhooks/overview).
* **[Webhook URL tunggal lama](/api-reference/organizations#legacy-single-url-webhook)**
  menerima satu percobaan sinkron (batas waktu 10 dtk, tanpa percobaan ulang) dengan
  payload yang sedikit berbeda — lihat
  [Perbedaan payload lama](#legacy-payload-differences).

## Payload permintaan (pengiriman endpoint)

```json
{
  "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",
    "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..."
    },
    "from_number": "+14155550199",
    "product": "spark",
    "recording_stopped_at": null,
    "recording_stopped_reason": null,
    "recording_url": "https://storage.example.com/…",
    "start_time": "2026-04-20T18:24:10.113Z",
    "status": "completed",
    "summary": "Alex Morgan called to book a follow-up visit and confirmed Thursday, April 23. The agent read the appointment back and Alex asked for a reminder text the day before.",
    "to_number": "+15551234567",
    "transcripts": [ /* see Transcript format */ ],
    "transfer_number": null,
    "unresolved_variables": ["campaign_owner"],
    "variables": {"campaign_name": "Spring renewals"},
    "voice": "john"
  },
  "event_id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
  "type": "telephony.complete"
}
```

| Field | Tipe | Deskripsi |
|-------|------|-------------|
| `call_id` | integer | Konsisten di setiap event untuk panggilan ini |
| `agent_id` | integer \| null | Agen yang menangani panggilan, jika ada yang ditetapkan |
| `agent_name` | string \| null | Agen yang menangani panggilan, jika ada yang ditetapkan |
| `direction` | string | `inbound`, `outbound`, `web`, `test`. Payload historis dapat berisi nilai lama `mic` atau `widget` |
| `from_number`, `to_number` | string | E.164. `from_number` secara literal bernilai `"web"` untuk panggilan web dan panggilan uji |
| `origin_domain` | string | **Hanya web/uji** — asal halaman yang menghosting widget (kosong untuk sesi mic) |
| `start_time`, `end_time` | timestamp | ISO 8601 UTC |
| `duration_seconds` | integer \| null | Dihitung dari waktu mulai/selesai |
| `status` | string | `completed` atau `failed` |
| `end_reason` | string | Lihat tabel di bawah |
| `product`, `voice` | string | Konfigurasi agen yang berlaku pada waktu panggilan |
| `variables` | object | Variabel input yang diambil sebagai snapshot saat panggilan dimulai |
| `unresolved_variables` | array | Nama variabel yang dirujuk oleh konfigurasi panggilan tetapi tidak diberikan saat panggilan dimulai |
| `transfer_number` | string \| null | Diatur saat panggilan ditransfer |
| `recording_url` | string \| null | URL bertanda tangan yang kedaluwarsa; unduh segera. `null` saat tidak ada artefak rekaman yang tersedia |
| `recording_stopped_at` | timestamp \| null | Waktu rekaman dihentikan sebelum panggilan berakhir; tidak ada bagian setelah waktu ini dalam rekaman. `null` saat rekaman berjalan hingga akhir panggilan atau panggilan tidak direkam |
| `recording_stopped_reason` | string \| null | Alasan rekaman berhenti lebih awal. `warm_transfer`: rekaman berhenti tepat sebelum [transfer hangat](/id/guides/call-transfers) menghubungi tujuan, dan tetap nonaktif meskipun transfer gagal dan penelepon kembali ke agen. Perlakukan nilai lain sebagai informasi; nilai tambahan dapat ditambahkan. `null` saat `recording_stopped_at` bernilai `null` |
| `billable_minutes` | number | Menit yang ditagihkan, dibulatkan ke seperempat menit terdekat (penambahan 15 detik, minimum 0.25). Panggilan langsung ke pesan suara tetap melaporkan menit terukur sebenarnya di sini, tetapi biayanya dibatasi hingga satu menit pada tarif paket. |
| `billing_total_cents` | integer | Sen USD |
| `transcripts` | array | Entri transkrip per giliran; dapat kosong saat transkrip tidak tersedia |
| `summary` | string \| null | Ringkasan pascapanggilan dalam bahasa sederhana (dua hingga empat kalimat prosa, maksimal 2.000 karakter, ditulis dalam bahasa panggilan: bahasa utama agen kecuali panggilan menggantinya) saat agen mengaktifkan **Buat ringkasan panggilan** di bawah **Data yang dikumpulkan** dalam pembuat agen (`call_summary_enabled` pada [Agen](/api-reference/agents)) dan pembuatan berhasil; selain itu `null`, termasuk untuk panggilan yang terlalu singkat untuk diringkas, panggilan tanpa transkrip, dan agen dengan rekaman dinonaktifkan. Ringkasan dibuat pada langkah pascapanggilan yang sama dengan ekstraksi, sehingga pengaturan **Tunggu data yang diekstrak sebelum mengirim webhook panggilan selesai** (`extraction_blocks_completion`) menentukan apakah event ini menunggunya: dalam mode menunggu, ringkasan siap sebelum event ini dikirim (ringkasan itu sendiri mendapat waktu maksimal 30 detik dalam anggaran 75 detik langkah tersebut); saat event tidak menunggu (`never`, atau transfer di bawah `except_transfers`) field ini bernilai `null` dan ringkasan **tidak** dikirim ulang dalam `call.data_extracted`, jadi baca nanti dari `summary` pada [objek Panggilan](/api-reference/calls#call-object). Juga tersedia pada halaman panggilan di dashboard |
| `extracted_data` | object \| null | Hasil ekstraksi terstruktur dengan `status`, `fields`, `evidence`, `verification`, `field_reasons`, dan `schema_version`. Setiap field non-null memiliki kutipan persis yang diperiksa secara struktural (maksimal 1.000 karakter), beserta peran pembicara dan indeks gilirannya; kutipan yang dikembalikan Model dan lebih panjang ditolak alih-alih dipotong. Evidence bernilai `null` saat field bernilai `null`. `verified` berarti setiap kandidat menerima tepat satu putusan independen yang valid. `unavailable` juga mencakup output pemverifikasi yang tidak valid atau parsial; putusan parsial yang valid tetap diterapkan, sedangkan kandidat tanpa satu putusan valid diatur menjadi null. `status` bernilai `completed`, `failed`, `exhausted`, `skipped`, atau `skipped_recording_disabled`; `null` saat agen tidak memiliki field ekstraksi |
| `steps_trace` | array | Hanya pada panggilan yang agennya memiliki langkah atau handoff. Urutan panggilan melewatinya: setiap entri memiliki `t_ms` (milidetik sejak panggilan dimulai), `agent` (kunci agen, `a` ditambah ID-nya), `step` (ID langkah, atau `null` pada handoff ke agen tanpa langkah), dan `via` (`start`, `model`, `condition`, `on_enter`, atau `handoff`). Hanya menyimpan ID dan waktu, sehingga tetap disimpan meskipun rekaman dinonaktifkan |
| `collected_values` | object | Hanya pada panggilan yang agennya memiliki langkah atau handoff. Nilai yang dikumpulkan agen dalam langkahnya, dengan kunci nama field collect, ditambah `<tool>_result` untuk hasil `on_enter_tool` setiap langkah. Dihilangkan saat konten panggilan tidak disimpan (rekaman dinonaktifkan) |

### Alasan berakhir

| Nilai | Arti |
|-------|---------|
| `user_hangup` | Pihak jarak jauh menutup panggilan terlebih dahulu |
| `ai_hangup` | AI sengaja mengakhiri panggilan |
| `ai_transfer` | AI mentransfer panggilan; `transfer_number` ditetapkan |
| `ai_warm_transfer` | AI menyelesaikan transfer hangat (dengan pendampingan) |
| `voicemail_hangup` | Pesan suara terdeteksi dan panggilan diakhiri sesuai `voicemail_action` Anda |
| `max_duration` | Panggilan mencapai batas durasi maksimum: batas agen atau batas panggilan itu sendiri, atau batas Platform 6 jam. Panggilan yang diakhiri oleh AI atau penelepon setelah peringatan batas waktu tetap menggunakan `ai_hangup` atau `user_hangup` |
| `superseded` | Sesi digantikan oleh sesi yang lebih baru |
| `inbound_config_webhook_failed` | Panggilan masuk ke nomor dalam mode webhook: [webhook konfigurasi](/id/webhooks/call-incoming) tidak mengembalikan konfigurasi yang dapat digunakan, sehingga panggilan diakhiri sebelum agen menjawab. `status` adalah `failed` dan panggilan tidak ditagihkan |
| `unknown` | Alasan pengakhiran tidak dapat ditentukan |

## Format transkrip

Setiap entri dalam `transcripts` adalah satu giliran percakapan. Perannya adalah
`user` (ucapan penelepon), `model` (ucapan agen **dan** panggilan alat),
`tool` (hasil alat), dan `system` (peristiwa panggilan seperti perpindahan
bahasa).

```json
[
  {
    "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"] }
    }
  }
]
```

| Kolom | Tipe | Deskripsi |
|-------|------|-------------|
| `role` | string | `user`, `model`, `tool`, atau `system` |
| `content_type` | string | `text/plain` untuk ucapan; `application/json` untuk panggilan alat, hasil alat, dan peristiwa sistem |
| `content` | string \| object | Teks ucapan, atau objek terstruktur yang ditampilkan di atas. Panggilan alat: `{"tool_call": name, "arguments": {…}}`. Hasil alat: `{"tool_name": name, "response": {…}}` |
| `start_ms`, `end_ms` | integer | Offset dari awal panggilan, dalam ms. Ada saat pengaturan waktu audio diketahui |
| `ttfa_ms` | integer | Waktu hingga audio pertama untuk giliran `model`, jika diukur |
| `audio_url`, `audio_urls` | string / array | URL bertanda tangan yang kedaluwarsa untuk audio giliran tersebut, jika direkam per giliran |

Untuk riwayat giliran yang sepenuhnya terstruktur (dengan penanda interupsi,
prompt pengakuan, dan posisi mentah), gunakan
[`GET /v1/calls/{call_id}/history`](/api-reference/calls#get-history).

## Perbedaan payload lama

Envelope webhook lama dengan satu URL adalah
`{"type": "telephony.complete" | "web.complete", "data": {…}}` tanpa
**`event_id`**, dan `data`-nya berbeda dari payload endpoint:

Payload penyelesaian lama juga memuat `agent_id` dan `agent_name`.

* Array giliran berada di bawah **`history`**, bukan `transcripts` (dengan skema
  giliran yang sama seperti di atas).
* Kumpulan kolomnya adalah laporan mentah akhir panggilan dan dapat mencakup
  kolom internal tambahan di luar tabel di atas — perlakukan kolom yang tidak
  dikenal sebagai informasi.
* Panggilan web (`direction: "web"`) **tidak menyertakan** `from_number` / `to_number`
  dan menambahkan `origin_domain`.
* Panggilan uji mikrofon Builder dilaporkan sebagai `telephony.complete` pada jalur
  lama (sistem endpoint memetakannya ke `web.complete`).
* **Koordinasi transfer:** saat panggilan berakhir dalam transfer, webhook
  lama dipanggil secara sinkron dan dapat menjawab
  `{"transfer_ready": false}` untuk menandakan target serah terima belum
  siap. Respons lainnya (atau tidak ada webhook lama) memungkinkan transfer
  dilanjutkan. Pengiriman endpoint tidak pernah digunakan untuk hal ini.

---

## Contoh handler

<CodeGroup>
```python Python (FastAPI)
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)
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>

---

## Kasus penggunaan umum

<CardGroup cols={2}>
  <Card title="Integrasi CRM" icon="database">
    Simpan transkrip + URL rekaman setiap panggilan bersama catatan
    pelanggan Anda.
  </Card>
  <Card title="Analitik" icon="chart-line">
    Alirkan transkrip ke pipeline untuk pemodelan topik, ekstraksi sinyal CSAT,
    atau pemantauan tingkat transfer.
  </Card>
  <Card title="Tinjauan kualitas" icon="clipboard-check">
    Buka panggilan di alat QA untuk ditinjau manusia, atau jalankan melalui
    model evaluasi Anda sendiri.
  </Card>
  <Card title="Notifikasi" icon="bell">
    Picu rekan tim manusia saat transfer / kegagalan.
  </Card>
</CardGroup>

---

## Terkait

<CardGroup cols={2}>
  <Card title="telephony.incoming / web.incoming" icon="phone" href="/id/webhooks/call-incoming">
    Pasangan pemblokiran yang berjalan saat panggilan dimulai.
  </Card>
  <Card title="Katalog peristiwa" icon="list" href="/id/webhooks/events">
    Jenis peristiwa lain yang dapat Anda langgani.
  </Card>
  <Card title="API riwayat panggilan" icon="phone" href="/api-reference/calls">
    Data yang sama dapat diakses melalui REST untuk pengisian ulang / pemutaran ulang.
  </Card>
</CardGroup>
