> ## 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.

# Webhooks 概览

> 了解 ThunderPhone 如何传递实时事件、如何验证签名，以及旧版投递模型与基于端点的投递模型的对比。

ThunderPhone 会在通话期间发生事件时向您的服务器发送 HTTP `POST` 请求——例如入站通话开始、通话结束、评分运行完成、触发告警等。共有**两种投递模式**：

<CardGroup cols={2}>
  <Card title="Webhook 端点（推荐）" icon="bolt" href="/zh/webhooks/endpoints">
    支持多个 URL、每个端点独立的密钥、每个端点独立的事件筛选，以及自动重试。
    通过 `GET/POST/PATCH/DELETE /v1/developer/webhook-endpoints` 管理。
  </Card>

  <Card title="单 URL 旧版 Webhook" icon="link" href="/api-reference/organizations#legacy-single-url-webhook">
    每个组织一个 URL。承载通话生命周期事件，包括**阻塞式**配置交换。通过 `GET/PUT /v1/webhook` 管理。
  </Card>
</CardGroup>

[事件目录](/zh/webhooks/events)中的全部十种事件类型都会通过 Webhook 端点投递。六种通话生命周期事件
（`telephony.incoming`、`telephony.complete`、`telephony.tool`、
`web.incoming`、`web.complete`、`web.tool`）也会发送到旧版单 URL Webhook——如果您同时配置了旧版 URL 和匹配的端点，您会在**两个**路径上收到该事件。阻塞行为（[`telephony.incoming` / `web.incoming` 配置交换](/zh/webhooks/call-incoming)以及 Webhook 模式的[工具分派](/zh/tools/overview)）仅存在于旧版路径；每次端点投递都是即发即忘的通知。

## 载荷格式

端点投递的数据是一个包含 `data`、`event_id` 和 `type` 的 JSON 对象：

```json theme={null}
{
  "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`：

```json theme={null}
{
  "type": "telephony.incoming",
  "data": { "call_id": 987654321, "from_number": "+14155550199", "to_number": "+15551234567" }
}
```

在线路上传输时，每个请求体都会以规范形式序列化——键按字母顺序排序、无空白字符、使用 UTF-8。这些文档中的格式化示例仅用于提高可读性。

请参阅[事件目录](/zh/webhooks/events)，获取事件类型和载荷字段的完整列表。

## 签名验证

每个请求都会在 `X-ThunderPhone-Signature` 标头中携带针对**原始请求
正文**的 HMAC-SHA256 签名。签名密钥是端点的 `secret`（对于旧版
投递，则为组织级 webhook 的 `secret`）。

### 步骤

1. 在进行任何解析**之前**读取原始请求正文。
2. 计算 `hmac_sha256(secret, body).hexdigest()`。
3. 使用恒定时间比较结果与 `X-ThunderPhone-Signature` 标头。

我们对传输的确切字节进行签名，这些字节采用规范 JSON 序列化格式（键已排序，分隔符紧凑）。因此，针对原始正文进行验证始终有效——如果您的框架仅提供已解析的 JSON，使用已排序的键和紧凑分隔符重新序列化即可生成完全相同的字节。两种方法均在[验证指南](/zh/guides/verify-webhook-signatures)中介绍。

<CodeGroup>
  ```python Python theme={null}
  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 "", 204
  ```

  ```javascript Node.js (Express) theme={null}
  import 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);
    },
  );
  ```
</CodeGroup>

## 投递语义

这些语义适用于投递到**端点**的事件。旧版单 URL
webhook 仅进行一次同步尝试，不会重试。

<AccordionGroup>
  <Accordion title="重试">
    每个事件会立即尝试投递一次。任何 `2xx` 响应
    都会确认投递。出现任何其他结果（非 2xx、
    连接错误、超时）时，我们会在**首次尝试后的 1 分钟、5 分钟、30 分钟、2 小时、6 小时、
    12 小时和 24 小时**进行重试——共 8 次尝试，覆盖
    24 小时。如果所有尝试均失败，投递将停止，端点
    会在[webhook 端点](/zh/webhooks/endpoints)中被标记为
    `status="failing"`。一旦持久化接受载荷，请尽快返回 `2xx`；
    请异步处理。
  </Accordion>

  <Accordion title="顺序">
    投递顺序尽力保证。实际上，我们会按照事件发出的
    顺序进行投递，但重试可能会在失败后改变顺序。
    始终根据 `call_id` / 对象 id 进行去重和协调。
  </Accordion>

  <Accordion title="重复">
    投递采用**至少一次**语义：在我们未收到响应后进行的重试
    可能会重复投递事件。每次重试都会携带相同的
    `event_id`，因此请存储已处理的 id 并跳过重复项。`event_id`
    也会在端点之间共享——订阅同一事件的两个端点会收到相同的
    `event_id`。
  </Accordion>

  <Accordion title="超时">
    每次端点投递的超时时间为 **30 秒**。在旧版路径上，会影响实时通话行为的
    阻塞请求——[`telephony.incoming` / `web.incoming`](/zh/webhooks/call-incoming)
    配置交换——会在 **10 秒**后超时，但较慢的响应会延迟接听通话，
    因此请尽量在数秒内响应。Webhook 模式的[工具调度](/zh/tools/overview)允许 20 秒。
  </Accordion>

  <Accordion title="源 IP">
    出站 webhook 来自 ThunderPhone 的云 IP 范围。
    如果您的防火墙需要允许列表，请联系支持团队，我们将
    提供当前的 IP 范围。
  </Accordion>
</AccordionGroup>

## 在旧版 webhook 与基于端点的 webhook 之间选择

| 功能       | 旧版（`/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`](/zh/webhooks/call-incoming)） | 从不——仅通知                               |
| 最适合      | 动态通话配置                                                                 | 生产环境中的事件消费                            |

新集成应通过基于端点的 webhook 使用事件。仅当您需要在接听时
动态配置通话，或使用 webhook 模式工具调度时，才保留（或添加）旧版 URL——这些
请求/响应交换仅在旧版路径上运行。

***

## 相关内容

<CardGroup cols={2}>
  <Card title="事件目录" icon="list" href="/zh/webhooks/events">
    所有事件类型及其载荷。
  </Card>

  <Card title="Webhook 端点" icon="bolt" href="/zh/webhooks/endpoints">
    管理多个端点、事件筛选器和密钥。
  </Card>

  <Card title="telephony.incoming / web.incoming" icon="phone" href="/zh/webhooks/call-incoming">
    您的服务器必须响应以配置通话的阻塞请求。
  </Card>

  <Card title="telephony.complete / web.complete" icon="phone" href="/zh/webhooks/call-complete">
    包含转录文本、录音和指标的通话后载荷。
  </Card>
</CardGroup>
