telephony.complete / web.complete
خطاف ويب غير حاجب يُرسَل عند انتهاء مكالمة، ويتضمن النص المفرغ ورابط التسجيل والمقاييس.
يُطلَق حدث الإكمال بعد انتهاء كل مكالمة — الاتصالات الهاتفية الواردة، والاتصالات الهاتفية الصادرة، ومكالمات الويب، أو المكالمات التجريبية (جلسة ميكروفون أداة الإنشاء). وهو غير حاجب: استجب بأي رمز 2xx.
يُسلَّم الحدث عبر كلا المسارين:
- تستقبل نقاط نهاية Webhook
telephony.complete(المكالمات الهاتفية) أوweb.complete(مكالمات الويب ومكالمات اختبار ميكروفون أداة الإنشاء) بالحمولة المستقرة الموضحة أدناه، وevent_idلكل عملية تسليم، ومهلة قدرها 30 ثانية، و إعادات محاولة لمدة تصل إلى 24 ساعة. - يستقبل Webhook القديم أحادي الرابط محاولة متزامنة واحدة (مهلة قدرها 10 ثوانٍ، دون إعادة محاولة) بحمولة مختلفة قليلًا — راجع اختلافات الحمولة القديمة.
حمولة الطلب (عمليات التسليم إلى نقاط النهاية)
{
"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"
}| الحقل | النوع | الوصف |
|---|---|---|
call_id | عدد صحيح | ثابت عبر كل حدث لهذه المكالمة |
direction | سلسلة نصية | inbound، وoutbound، وweb، وtest. قد تحتوي الحمولات السابقة على القيم القديمة mic أو widget |
from_number، to_number | سلسلة نصية | E.164. تكون قيمة from_number الحرفية هي "web" لمكالمات الويب والمكالمات التجريبية |
origin_domain | سلسلة نصية | للويب/الاختبار فقط — مصدر الصفحة التي استضافت الأداة (فارغ لجلسات الميكروفون) |
start_time، end_time | طابع زمني | ISO 8601 UTC |
duration_seconds | عدد صحيح | null | مشتق من وقت البدء/الانتهاء |
status | سلسلة نصية | completed أو failed |
end_reason | سلسلة نصية | راجع الجدول أدناه |
product، voice | سلسلة نصية | إعدادات الوكيل السارية وقت المكالمة |
transfer_number | سلسلة نصية | null | تُعيَّن عند تحويل المكالمة |
recording_url | سلسلة نصية | null | رابط موقّع تنتهي صلاحيته؛ نزّله فورًا. تكون القيمة null عند عدم توفر ملف تسجيل |
billable_minutes | رقم | الدقائق المفوترة، مقربة إلى أقرب ربع دقيقة (بزيادات قدرها 15 ثانية، وبحد أدنى 0.25). لا تزال مكالمات التحويل المباشر إلى البريد الصوتي تعرض دقائقها الفعلية المقاسة هنا، لكن تُحدَّد الرسوم بحد أقصى دقيقة واحدة وفق سعر الباقة. |
billing_total_cents | عدد صحيح | سنتات الدولار الأمريكي |
transcripts | مصفوفة | إدخالات النص المفرغ لكل دور؛ قد تكون فارغة عند عدم توفر نص مفرغ |
أسباب الإنهاء
| القيمة | المعنى |
|---|---|
user_hangup | أنهى الطرف الآخر المكالمة أولًا |
ai_hangup | أنهى الذكاء الاصطناعي المكالمة عمدًا |
ai_transfer | حوّل الذكاء الاصطناعي المكالمة؛ وتُعيَّن قيمة transfer_number |
ai_warm_transfer | أكمل الذكاء الاصطناعي تحويلًا دافئًا (بحضور الطرفين) |
voicemail_hangup | تم اكتشاف البريد الصوتي وانتهت المكالمة وفق voicemail_action الخاص بك |
max_duration | بلغت المكالمة الحد الأقصى للمدة |
superseded | استُبدلت الجلسة بجلسة أحدث |
unknown | تعذر تحديد سبب الإنهاء |
تنسيق النص المفرغ
كل إدخال في transcripts هو دور واحد في المحادثة. الأدوار هي
user (كلام المتصل)، وmodel (كلام الوكيل واستدعاءات الأدوات)،
وtool (نتائج الأدوات)، وsystem (أحداث المكالمة مثل تبديل
اللغة).
[
{
"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"] }
}
}
]| الحقل | النوع | الوصف |
|---|---|---|
role | سلسلة نصية | user أو model أو tool أو system |
content_type | سلسلة نصية | text/plain للكلام؛ وapplication/json لاستدعاءات الأدوات ونتائجها وأحداث النظام |
content | سلسلة نصية | كائن | نص الكلام، أو الكائن المنظّم الموضح أعلاه. استدعاءات الأدوات: {"tool_call": name, "arguments": {…}}. نتائج الأدوات: {"tool_name": name, "response": {…}} |
start_ms، end_ms | عدد صحيح | إزاحات ابتداءً من بدء المكالمة، بالمللي ثانية. تكون موجودة عند معرفة توقيت الصوت |
ttfa_ms | عدد صحيح | الوقت حتى أول صوت لدور model، عند قياسه |
audio_url، audio_urls | سلسلة نصية / مصفوفة | عناوين URL موقعة منتهية الصلاحية لصوت الدور، عند تسجيل الصوت لكل دور |
لاستخدام سجل الأدوار المنظّم بالكامل (مع علامات المقاطعة،
وموجّهات الإقرار، والمواضع الأولية)، استخدم
GET /v1/calls/{call_id}/history.
اختلافات الحمولة القديمة
غلاف خطاف الويب القديم ذي عنوان URL الواحد هو
{"type": "telephony.complete" | "web.complete", "data": {…}} من دون
event_id، وتختلف قيمة data فيه عن حمولة نقطة النهاية:
- توجد مصفوفة الأدوار ضمن
history، وليسtranscripts(وبنفس مخطط الأدوار أعلاه). - مجموعة الحقول هي تقرير نهاية المكالمة الأولي، وقد تتضمن حقولًا داخلية إضافية غير المذكورة في الجدول أعلاه — تعامل مع الحقول غير المعروفة على أنها معلوماتية.
- مكالمات الويب (
direction: "web") لا تتضمنfrom_number/to_numberوتضيفorigin_domain. - تُبلّغ مكالمات اختبار الميكروفون في أداة الإنشاء على أنها
telephony.completeعبر المسار القديم (يحوّلها نظام نقطة النهاية إلىweb.complete). - تنسيق التحويل: عند انتهاء مكالمة بتحويل، يُستدعى خطاف الويب القديم بشكل متزامن، وقد يجيب بـ
{"transfer_ready": false}للإشارة إلى أن هدف التسليم غير جاهز. يسمح أي رد آخر (أو عدم وجود خطاف ويب قديم) بمتابعة التحويل. لا تتم استشارة عمليات التسليم عبر نقطة النهاية لهذا الغرض.
مثال على معالج
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}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 });
},
);حالات الاستخدام الشائعة
احفظ النص المفرغ لكل مكالمة ورابط التسجيل إلى جانب سجلات عملائك.
أرسل النصوص المفرغة إلى مسار معالجة لنمذجة الموضوعات، أو استخراج مؤشرات CSAT، أو مراقبة معدل التحويل.
افتح المكالمات في أداة ضمان الجودة لمراجعتها بشريًا، أو مررها عبر نموذج التقييم الخاص بك.
نبّه زميلًا بشريًا عند التحويل أو الإخفاق.