Webhook 概述
了解 ThunderPhone 如何传送实时事件、如何验证签名,以及旧版与基于端点的传送模型如何比较。
ThunderPhone 会在通话期间发生事件时向您的服务器发送 HTTP POST 请求——例如呼入通话开始、通话结束、评分运行完成、触发警报等。提供 两种交付模型:
支持多个 URL、每个端点独立的密钥、每个端点独立的事件筛选器,
以及自动重试。
通过 GET/POST/PATCH/DELETE /v1/developer/webhook-endpoints 管理。
每个组织一个 URL。承载通话生命周期事件,包括
阻塞式配置交换。通过 GET/PUT /v1/webhook 管理。
事件目录中的全部十种事件类型都会通过 Webhook 端点
交付。六种通话生命周期事件
(telephony.incoming、telephony.complete、telephony.tool、
web.incoming、web.complete、web.tool)也会发送到
旧版单 URL Webhook——如果您同时配置了旧版 URL 和匹配的端点,您会在
两个路径上收到该事件。阻塞行为(telephony.incoming / web.incoming 配置
交换以及 Webhook 模式的
工具分派)仅存在于旧版路径;
所有端点交付均为即发即弃通知。
负载格式
端点交付的数据是一个包含 data、event_id 和
type 的 JSON 对象:
{
"data": {
"call_id": 987654321,
"from_number": "+14155550199",
"to_number": "+15551234567"
},
"event_id": "3f6b2ad0-1c9e-4a57-9f2b-8f6f0f9d2f11",
"type": "telephony.incoming"
}event_id 对每个发出的事件都是唯一的。它在重试期间
以及在接收该事件的每个端点之间保持一致——请基于它进行去重。
旧版单 URL Webhook 发送相同的 type 和 data,但
不包含 event_id:
{
"type": "telephony.incoming",
"data": { "call_id": 987654321, "from_number": "+14155550199", "to_number": "+15551234567" }
}在传输过程中,每个请求体均采用规范化序列化——键按字母顺序排序、 不含空白字符、使用 UTF-8。这些文档中的美化格式示例仅为便于阅读。
有关事件类型和负载字段的完整列表,请参阅事件目录。
签名验证
每个请求都会在 X-ThunderPhone-Signature 请求头中携带针对原始请求体的 HMAC-SHA256 签名。签名密钥是端点的 secret(对于旧版投递,则是您组织级 Webhook 的 secret)。
步骤
- 在进行任何解析之前读取原始请求体。
- 计算
hmac_sha256(secret, body).hexdigest()。 - 使用恒定时间比较方式与
X-ThunderPhone-Signature请求头进行比较。
我们签名的是实际传输的字节,这些字节采用规范化 JSON 序列化格式(键已排序,分隔符紧凑)。因此,针对原始请求体进行验证始终有效——如果您的框架仅提供已解析的 JSON,使用已排序的键和紧凑分隔符重新序列化即可生成完全相同的字节。两种方法均在验证指南中介绍。
import hmac
import hashlib
def verify_signature(body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode("utf-8"),
body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature or "")
# Example Flask handler
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/thunderphone-webhook")
def handle():
body = request.get_data()
sig = request.headers.get("X-ThunderPhone-Signature", "")
if not verify_signature(body, sig, WEBHOOK_SECRET):
abort(401)
event = request.get_json()
# dispatch on event["type"] …
return "", 204import crypto from "node:crypto";
import express from "express";
function verifySignature(body, signature, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(body)
.digest("hex");
if (!signature || expected.length !== signature.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature),
);
}
const app = express();
app.post(
"/thunderphone-webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.header("X-ThunderPhone-Signature") || "";
if (!verifySignature(req.body, sig, process.env.WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
// dispatch on event.type …
res.sendStatus(204);
},
);投递语义
这些语义适用于端点投递。旧版单 URL 网络钩子仅进行一次同步尝试,不会重试。
重试
每个事件都会立即尝试投递一次。任何 2xx 响应
都会确认投递。对于其他任何结果(非 2xx、
连接错误、超时),我们会在首次尝试后的 1 分钟、5 分钟、30 分钟、2 小时、6 小时、
12 小时和 24 小时进行重试——共 8 次尝试,覆盖
24 小时。如果每次尝试均失败,投递将停止,端点
会在网络钩子端点中标记为
status="failing"。载荷被持久化接收后,请尽快返回 2xx;
请异步处理。
排序
投递顺序尽力保证。实际上,我们会按照事件发出的
顺序投递,但失败后的重试可能会改变顺序。
始终按 call_id / 对象 ID 去重并核对。
重复
投递为至少一次:在我们未收到响应后进行重试,
可能会导致事件重复。每次重试都会携带相同的
event_id,因此请存储已处理的 ID 并跳过重复项。event_id
也会在端点之间共享——订阅同一事件的两个端点会收到相同的 event_id。
超时
每次端点投递的超时时间为 30 秒。在
旧版路径上,驱动实时通话行为的阻塞请求——
telephony.incoming / web.incoming
配置交换——会在 10 秒后超时,但较慢的响应会延迟通话接听,因此应在几秒钟内
响应。网络钩子模式的工具调度默认允许 20 秒,
工具声明可设置顶级 timeout。
源 IP
出站网络钩子来自 ThunderPhone 的云 IP 范围。 如果您的防火墙需要允许列表,请联系支持团队,我们将 分享当前的范围。
在旧版网络钩子与基于端点的网络钩子之间进行选择
| 功能 | 旧版(/v1/webhook) | 端点(/v1/developer/webhook-endpoints) |
|---|---|---|
| URL 数量 | 每个组织 1 个 | 每个组织多个 |
| 事件覆盖范围 | 仅 telephony.* / web.* | 所有 10 种事件类型 |
| 事件筛选 | — | 每个端点单独设置 |
| 重试 | 无 | 24 小时内 8 次尝试 |
| 封装 | type + data | type + data + event_id |
| 密钥轮换 | 替换单个密钥 | 每个端点单独的密钥 |
| 无需删除即可禁用 | — | status=disabled |
| 状态可见性 | — | active / disabled / failing |
| 阻塞式配置交换 | 是(telephony.incoming / web.incoming) | 从不——仅通知 |
| 最适合 | 动态通话配置 | 生产环境中的事件消费 |
新集成应通过基于端点的 网络钩子接收事件。仅当您在接听时动态配置通话, 或使用网络钩子模式工具调度时,才保留(或添加)旧版 URL——这些 请求/响应交换仅在旧版路径上运行。