> For the complete documentation index, see [llms.txt](https://doc1.antgst.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://doc1.antgst.com/ant-api/voice/webhook.md).

# Webhook

## 外呼通话记录回调

### 1. 概述

推送采用 **HTTP POST + JSON** 方式，并使用 **HMAC-SHA256** 对请求体进行签名，接入方需在服务端校验签名以保证请求来源可信。

#### 1.1 关键特性

| 特性             | 说明                 |
| -------------- | ------------------ |
| 推送方式           | POST               |
| Content-Type   | `application/json` |
| 字符集            | UTF-8              |
| 签名算法           | HMAC-SHA256        |
| 签名传递           | 请求头 `X-Sign`       |
| 时间戳传递          | 请求头 `X-Timestamp`  |
| 最大重试次数         | 3 次                |
| 重试退避策略         | 指数退避：2s、4s         |
| HTTP 连接超时      | 3000 ms            |
| HTTP Socket 超时 | 3000 ms            |
| 成功判定           | HTTP 状态码等于 `200`   |

> **注意：** 只有 HTTP 状态码为 `200` 时才会被视为推送成功。其余状态码（包括 2xx 非 200、3xx、4xx、5xx）或网络异常都会触发重试。

***

### 2. 接入前置条件

接入方需要先在系统中完成以下配置，否则不会触发推送：

1. [**获取 `secretkey`**](https://web.antgst.com/client/account/workspaceApi)：作为 HMAC-SHA256 签名密钥。
2. [**配置回调URL**](https://web.antgst.com/client/account/webhooks) ：完整的回调接收 URL。
3. **回调 URL 必须公网可达**，且能够正常响应 HTTP 200。

> 若以上任一条件不满足，系统将跳过本次推送（不会重试、不会报错给接入方）。

***

### 3. 请求格式

#### 3.1 HTTP 请求

```
POST {callCallbackProportional} HTTP/1.1
Host: {your-host}
Content-Type: application/json
X-Timestamp: {unix-timestamp-seconds}
X-Sign: {hmac-sha256-hex}

{json-body}
```

#### 3.2 请求头说明

| 请求头            | 是否必填 | 说明                                  |
| -------------- | ---- | ----------------------------------- |
| `Content-Type` | 是    | 固定为 `application/json`              |
| `X-Timestamp`  | 是    | 发起请求时的 Unix 时间戳，单位：**秒**（10 位整数字符串） |
| `X-Sign`       | 是    | 签名值，HMAC-SHA256 输出的 **小写十六进制字符串**   |

#### 3.3 请求体

请求体为 JSON 对象，字段定义如下：

| 字段名                    | 类型      | 是否必返 | 说明                                                                                                               |
| ---------------------- | ------- | ---- | ---------------------------------------------------------------------------------------------------------------- |
| `taskId`               | Long    | 是    | 外呼任务 ID                                                                                                          |
| `countryCode`          | String  | 是    | 国家代码                                                                                                             |
| `caller`               | String  | 是    | 主叫号码                                                                                                             |
| `callee`               | String  | 是    | 被叫号码                                                                                                             |
| `seat`                 | String  | 否    | 坐席标识                                                                                                             |
| `duration`             | Integer | 是    | 通话时长（秒）                                                                                                          |
| `ringDuration`         | Integer | 是    | 响铃时长（秒）                                                                                                          |
| `transferDuration`     | Integer | 否    | 转接时长（秒）                                                                                                          |
| `transferRingDuration` | Integer | 否    | 转接响铃时长                                                                                                           |
| `startTime`            | String  | 是    | 呼叫开始时间                                                                                                           |
| `endTime`              | String  | 是    | 呼叫结束时间                                                                                                           |
| `callId`               | String  | 是    | 呼叫 ID，唯一标识一次通话                                                                                                   |
| `sipCode`              | Integer | 否    | SIP 状态码                                                                                                          |
| `callCode`             | String  | 是    | [呼叫结果码](https://doc1.antgst.com/ant-api/voice/pages/7TrybevQQvGpt7apMrfe#id-2.-quan-liang-cuo-wu-ma-su-cha-biao) |
| `answerTime`           | String  | 否    | 应答时间，无应答时不返回                                                                                                     |
| `transferStartTime`    | String  | 否    | 转接开始时间，未转接时不返回                                                                                                   |
| `transferEndTime`      | String  | 否    | 转接结束时间，未转接时不返回                                                                                                   |
| `transferAnswerTime`   | String  | 否    | 转接应答时间，未转接应答时不返回                                                                                                 |
| `recordFile`           | String  | 否    | 录音文件地址，为开启录音不返回                                                                                                  |
| `userInfo`             | String  | 否    | <p>被叫用户信息，未配置用户信息不返回<br><strong>注意：该字段为 JSON 字符串格式</strong>。调用方需自行将其解析（反序列化）为 JSON</p>                           |

> **说明：**
>
> * 标记为「否」的字段在无业务数据时不会出现在请求体中。

#### 3.4 请求体示例

```json
{
  "taskId": 2034897929860419600,
  "countryCode": "0086",
  "caller": "01088888888",
  "callee": "13800138000",
  "seat": "1007",
  "duration": 45,
  "ringDuration": 6,
  "transferDuration": 12,
  "startTime": "2026-07-13 10:00:00",
  "endTime": "2026-07-13 10:00:45",
  "callId": "2a5ecb5d-ee9a-4e97-a884-871066ef74ab",
  "sipCode": 0,
  "callCode": "225",
  "answerTime": "2026-07-13 10:00:06",
  "transferStartTime": "2026-07-13 10:00:30",
  "transferRingDuration": 3,
  "transferEndTime": "2026-07-13 10:00:42",
  "transferAnswerTime": "2026-07-13 10:00:33"
}
```

***

### 4. 签名算法

系统使用 **HMAC-SHA256** 对请求体进行签名。接入方在收到请求后，应使用相同的算法重新计算签名，并与请求头 `X-Sign` 比对，校验通过后再处理业务逻辑。

#### 4.1 签名步骤

1. 取出请求头 `X-Timestamp` 的值，记为 `timestamp`。
2. 读取原始请求体字符串（即 HTTP body 的原始 JSON 文本），记为 `jsonBody`。
3. 拼接签名内容：`signContent = timestamp + jsonBody`（直接字符串拼接，无分隔符）。
4. 以配置的 `secretkey` 作为密钥，对 `signContent` 计算 HMAC-SHA256。
5. 将计算结果转换为**小写十六进制字符串**，与请求头 `X-Sign` 比对。

> **重要：**
>
> * 拼接顺序为 **时间戳在前，请求体在后**，不可颠倒。
> * 必须使用**原始请求体字符串**参与签名，**不能**先反序列化再序列化后参与计算，因为 JSON 字段顺序、空格、换行等差异都会导致签名不一致。
> * 时间戳必须使用**秒**级 Unix 时间戳。

#### 4.2 签名示例（Java）

```java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;

public class SignUtil {

    private static final String HMAC_SHA256 = "HmacSHA256";

    public static String hmacSha256(String secret, String message) {
        try {
            Mac mac = Mac.getInstance(HMAC_SHA256);
            SecretKeySpec secretKeySpec =
                    new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_SHA256);
            mac.init(secretKeySpec);
            byte[] hash = mac.doFinal(message.getBytes(StandardCharsets.UTF_8));
            return bytesToHex(hash);
        } catch (Exception e) {
            throw new RuntimeException("签名计算失败", e);
        }
    }

    private static String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) {
            String hex = Integer.toHexString(0xff & b);
            if (hex.length() == 1) {
                sb.append('0');
            }
            sb.append(hex);
        }
        return sb.toString();
    }
}
```

调用示例：

```java
String timestamp = "1720838400";
String jsonBody = "{\"taskId\":100234,...}";
String secretKey = "your-secretkey";

String signContent = timestamp + jsonBody;
String sign = SignUtil.hmacSha256(secretKey, signContent);
// 将 sign 与请求头 X-Sign 进行比较
```

#### 4.3 签名示例（Python）

```python
import hmac
import hashlib

def calc_sign(secret: str, timestamp: str, json_body: str) -> str:
    sign_content = timestamp + json_body
    return hmac.new(
        secret.encode("utf-8"),
        sign_content.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()
```

#### 4.4 签名示例（Node.js）

```javascript
const crypto = require('crypto');

function calcSign(secret, timestamp, jsonBody) {
  const signContent = timestamp + jsonBody;
  return crypto
    .createHmac('sha256', secret)
    .update(signContent, 'utf8')
    .digest('hex');
}
```

***

### 5. 响应要求

接入方的回调接收接口需遵循以下响应约定：

| 项        | 要求                                      |
| -------- | --------------------------------------- |
| HTTP 状态码 | 处理成功返回 `200`，其余均视为失败                    |
| 响应耗时     | 建议在 **3 秒** 内返回（系统侧 Socket 超时为 3000 ms） |
| 响应体      | 无强制要求，可返回空或自定义 JSON                     |

> **建议：**
>
> * 接收到请求后先做**签名校验**，校验失败也应返回非 200（例如 401），以便系统区分「业务处理失败」与「鉴权失败」。
> * 业务处理耗时较长时，建议先返回 200 再异步处理，避免触发不必要的重试。
> * 接入方需保证回调接口**幂等**：同一条通话记录可能因网络抖动被推送多次，应以 `callId` 为唯一键去重。

***

### 6. 重试策略

单次推送内部采用 **指数退避重试**，最大尝试 3 次：

| 尝试次数  | 触发条件    | 等待下一次重试时间 |
| ----- | ------- | --------- |
| 第 1 次 | 初始推送    | 失败后等待 2 秒 |
| 第 2 次 | 第 1 次失败 | 失败后等待 4 秒 |
| 第 3 次 | 第 2 次失败 | 不再重试      |

重试触发的失败条件包括：

* HTTP 状态码不为 `200`；
* HTTP 调用抛出异常（连接超时、Socket 超时、网络中断等）；
* `CallbackResponse` 返回 `null`（HTTP 客户端内部异常）。

> **退避公式：** `backoffTime = 2^i * 1000` ms，其中 `i` 为当前已失败的尝试次数（从 1 开始）。
>
> **注意：** 系统仅在单次推送内部进行上述 3 次重试。3 次全部失败后，本次推送将被丢弃，不会再做持久化补偿。若接入方对可靠性要求较高，建议在自身服务端做好监控告警与丢失补偿。

***

### 7. 鉴权与安全建议

1. **校验时间戳**：建议接入方校验 `X-Timestamp` 与当前服务器时间的差值（例如 5 分钟以内），超出阈值则拒绝请求，防止重放攻击。
2. **校验签名**：必须使用 `secretkey` 重新计算签名并与 `X-Sign` 比对，禁止跳过。
3. **使用 HTTPS**：虽然系统支持 HTTP 推送，强烈建议接入方回调地址使用 HTTPS，避免签名密钥与通话记录在传输过程中泄露。
4. **保密 `secretkey`**：密钥仅用于服务端签名校验，不得下发到客户端或日志中。

***

### 8. 推送流程时序

```
系统侧                                    接入方服务端
  |                                            |
  |  通话结束，组装 CallRecordRequestDTO        |
  |  从缓存读取 secretkey / callbackUrl        |
  |                                            |
  |  生成 timestamp（秒）                       |
  |  计算 sign = HMAC-SHA256(secretkey,        |
  |           timestamp + jsonBody)            |
  |                                            |
  |  POST callbackUrl                          |
  |  Headers: X-Timestamp / X-Sign             |
  |  Body: jsonBody                            |
  |  ----------------------------------------> |
  |                                            |  读取原始 body
  |                                            |  校验 timestamp 时效
  |                                            |  重新计算 sign 并比对
  |                                            |  处理业务（幂等去重）
  |                                            |
  |  <---------------------------------------- |
  |  HTTP 200                                  |
  |                                            |
  |  若 200 -> 推送成功，结束                     |
  |  若非 200 或异常 -> 退避后重试（最多 3 次）      |
  |                                            |
```

***

### 9. 附录

#### 9.1 字段速查表

| 字段                  | 类型      | 说明                                                                                                               |
| ------------------- | ------- | ---------------------------------------------------------------------------------------------------------------- |
| `taskId`            | Long    | 任务 ID                                                                                                            |
| `caller` / `callee` | String  | 主叫 / 被叫号码                                                                                                        |
| `callId`            | String  | 呼叫唯一标识，用于幂等去重                                                                                                    |
| `duration`          | Integer | 通话时长（秒）                                                                                                          |
| `ringDuration`      | Integer | 响铃时长（秒）                                                                                                          |
| `transferDuration`  | Integer | 转接时长（秒）                                                                                                          |
| `sipCode`           | Integer | SIP 状态码                                                                                                          |
| `callCode`          | String  | [呼叫结果码](https://doc1.antgst.com/ant-api/voice/pages/7TrybevQQvGpt7apMrfe#id-2.-quan-liang-cuo-wu-ma-su-cha-biao) |
| `X-Timestamp`       | Header  | Unix 秒级时间戳                                                                                                       |
| `X-Sign`            | Header  | HMAC-SHA256 小写十六进制签名                                                                                             |

#### 9.2 常见问题排查

| 现象        | 可能原因                                               |
| --------- | -------------------------------------------------- |
| 收不到回调     | 未配置 `secretkey` 或 对应任务的回调地址；回调地址非公网可达              |
| 签名校验失败    | 未使用原始 body 字符串计算签名；时间戳与 body 拼接顺序错误；密钥不一致；密钥大小写不一致 |
| 频繁收到同一条记录 | 接入方响应非 200 或超时，触发系统重试；建议接口幂等                       |
| 偶发丢失记录    | 3 次重试均失败（网络抖动、接入方服务不可用），系统不再补偿                     |


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://doc1.antgst.com/ant-api/voice/webhook.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
