> 内容来源：Onevium 官方文档
> 文章: Webhook 与结果回调：事件接入、验签和投递
> 原文: https://onevium.com/zh/docs/developers/webhooks
> 语言：简体中文
> 更新于: 2026-09-09
> 适用版本: 1.1.23+

---

# Webhook 与结果回调：事件接入、验签和投递

配置入站业务事件与出站结果回调，复制验签示例，核对事件去重和真实投递结果。

## 先区分进入与返回

**入站 Webhook**：工单系统发生事件，向 Onevium 提交任务。**结果回调**：任务产生选定事件，Onevium 向你的接收服务投递结果。两者使用不同地址、不同签名密钥，可以单独启用。

下面用工单事件演示两条方向的配置和验证。截图来自真实预览组件和隔离演示数据，未发送真实业务请求。

## 准备一个完整测试场景

先按[外部接入](https://onevium.com/zh/docs/developers/external-access)创建应用，绑定助手，完成能力检查并发布服务、启用接入。本文用虚构工单 `ticket-42`，业务主体固定为 `support-demo`。`gateway.example.com`、`hooks.example.com` 都是占位域名，实际发送前换成你控制且已配置好的地址。

地址先分清：若界面“API 地址”为 `http://127.0.0.1:18420/api/v1`，用于其他 API 示例的 `GATEWAY_URL` 只填 `http://127.0.0.1:18420`，去掉末尾 `/api/v1`。本页的 `ONEVIUM_WEBHOOK_URL` 则是 Webhook 卡片上的**完整地址**，保留 `/api/v1/webhooks/...`，不再追加前缀。回调 URL 同样保留接收器路径。

业务链路如下：

1. 工单更新 → 发送 `ticket.updated`。
2. 网关按 `ticket_id` 找到或创建业务会话，返回接纳回执。
3. 业务后端保存 `ticket-42` 与 `conversation_id/run_id` 的对应关系。
4. 助手执行；业务后端通过查询、SSE 或可选回调取得结果。

本地业务系统可以直接查询或订阅 SSE。**出站回调目前只支持管理员名单内的公网 HTTPS 地址，不支持 localhost、局域网或 IP 字面量，端口只能是默认 443。** 不具备公网接收服务时，不必为了测试 API 开启回调。

## 配置入站 Webhook：点哪里、填什么

打开「渠道 → 外部接入」，选中已创建的应用，在「事件与回调」点「添加 Webhook」。为本例填写：

![Webhook 配置上半部分：选择服务、事件类型、业务主体和事件动作。](https://onevium.com/docs/external-access/3cc390708/zh/12-webhook-message.png "Webhook 配置上半部分：选择服务、事件类型、业务主体和事件动作。")

| 表单字段     | 本例值              | 含义                |
| -------- | ---------------- | ----------------- |
| 助手服务     | 已发布的测试助手         | 事件交给谁执行           |
| 事件类型字段   | `type`           | 在 JSON 中读取事件类型的位置 |
| 事件类型     | `ticket.updated` | 精确允许值；多个值用逗号分隔    |
| 业务主体     | `support-demo`   | 固定主体；不是 JSON 字段路径 |
| 事件动作     | 继续会话             | 对应 `message`      |
| 消息字段     | `text`           | 给助手的文字            |
| 结构化数据字段  | `data`           | 本例为 JSON 对象       |
| 资源 ID 字段 | 留空               | 本例不传附件            |
| 会话匹配     | 业务会话键            | 使用业务标识关联上下文       |
| 会话键字段    | `ticket_id`      | 同一工单继续同一业务会话      |
| 启用       | 勾选               | 允许接收事件            |

![滚动后的字段近景：映射消息内容与业务会话标识，延续对应会话。](https://onevium.com/docs/external-access/3cc390708/zh/13-webhook-mapping.png "滚动后的字段近景：映射消息内容与业务会话标识，延续对应会话。")

点「保存」，保存新生成的签名密钥。应用页面会显示完整地址：

```text
POST <网关 origin，不含 /api/v1>/api/v1/webhooks/<integration_id>
```

直接复制该地址；尾部是 Webhook 的 `integration_id`，不是助手 endpoint ID。密钥不能在保存后随时读回，遗失时使用编辑里的「轮换签名密钥」，并同步更新发送端。

字段路径使用 `data.text` 这样的点号路径，最多五段，不是任意 JSONPath。当前表单的业务主体是固定字符串；请求中的 `subject` 不能覆盖它。事件类型清单必须非空；不匹配的事件返回 403，而不是静默接受。

### 其他动作怎么选

| 动作                     | 额外填写          | 行为                             |
| ---------------------- | ------------- | ------------------------------ |
| 新建会话 `new`             | 消息／数据／资源字段    | 每个新事件单独建会话，不绑定已有会话键或 ID        |
| 继续会话 `message`，按 ID 匹配 | 会话 ID 字段＋代际字段 | 两个字段都要有，代际必须真实有效               |
| 重开会话 `reset`           | 会话 ID、代际、忙碌策略 | 选择拒绝重开或停止后重开；查询 operation 确认完成 |
| 停止运行 `cancel`          | Run ID 字段     | 只控制该服务与主体有权操作的 Run             |

![重开会话字段近景：配置会话 ID 与轮次的字段映射，以及任务运行时的处理策略。](https://onevium.com/docs/external-access/3cc390708/zh/14-webhook-reset.png "重开会话字段近景：配置会话 ID 与轮次的字段映射，以及任务运行时的处理策略。")

文本、结构化数据和资源至少要提供一种有效输入。资源必须先经授权上传取得 ID，不要发送本机路径或让字段映射透传工具权限。

## 发送一个正确签名的事件

在业务后端保存 `ticket-event.json`：

```json
{
  "type": "ticket.updated",
  "ticket_id": "ticket-42",
  "text": "Summarize this test ticket and suggest the next check. Do not modify external systems.",
  "data": {
    "ticket_id": "ticket-42",
    "summary": "A test device cannot connect after restart"
  }
}
```

新建私有 `webhook.env`，把下面两个值替换成页面给出的值：

```dotenv
ONEVIUM_WEBHOOK_URL=https://gateway.example.com/api/v1/webhooks/YOUR_INTEGRATION_ID
ONEVIUM_WEBHOOK_SECRET=PASTE_THE_INCOMING_WEBHOOK_SECRET
```

内置模式在同一台机器可使用页面显示的 `http://127.0.0.1:实际端口` 地址；远端必须 HTTPS。环境文件不要提交到 Git。

下面的 `send-ticket.mjs` 使用 Node.js 20.18 或以上版本，无需额外依赖：

```javascript
import { createHmac } from 'node:crypto';
import { readFile } from 'node:fs/promises';

const url = new URL(process.env.ONEVIUM_WEBHOOK_URL);
const secret = process.env.ONEVIUM_WEBHOOK_SECRET;
const eventId = process.argv[2];
if (!secret || secret.length < 32) throw new Error('Missing webhook secret');
if (!/^[\x21-\x7e]{1,128}$/.test(eventId || '')) throw new Error('Invalid event ID');
if (url.protocol !== 'https:' &&
    !(url.protocol === 'http:' && url.hostname === '127.0.0.1')) {
  throw new Error('Use HTTPS, or the displayed local loopback gateway');
}
const body = await readFile('ticket-event.json');
if (body.length > 1024 * 1024) throw new Error('Payload exceeds 1 MiB');
JSON.parse(body.toString('utf8'));
const timestamp = String(Math.floor(Date.now() / 1000));
const signature = createHmac('sha256', secret)
  .update(`${eventId}.${timestamp}.`).update(body).digest('hex');
const response = await fetch(url, {
  method: 'POST',
  redirect: 'error',
  signal: AbortSignal.timeout(30_000),
  headers: {
    'Content-Type': 'application/json',
    'webhook-id': eventId,
    'webhook-timestamp': timestamp,
    'webhook-signature': `v1,${signature}`,
  },
  body,
});
console.log(response.status, await response.text());
if (!response.ok) process.exitCode = 1;
```

```bash
node --env-file=webhook.env send-ticket.mjs ticket-42-version-1
```

本例应取得 202 及 `conversation_id`、`generation`、`message_id`、`run_id` 等接纳信息；这些是**预期结果**，应以你实际响应为准。202 只表示持久接纳，不表示助手已完成。

## 签名、重放与重复事件

入站与出站使用相同的签名格式，但密钥不同：

```text
待签名字节 = UTF-8(event_id + "." + timestamp + ".") + 原始请求体字节
签名 = HMAC-SHA256(密钥原文的 UTF-8 字节, 待签名字节)
```

| 请求头                 | 内容                               |
| ------------------- | -------------------------------- |
| `webhook-id`        | 稳定事件 ID；1–128 个可打印 ASCII 字符，不含空格 |
| `webhook-timestamp` | Unix 秒级时间戳                       |
| `webhook-signature` | `v1,` 加 64 位小写十六进制签名             |
| `Content-Type`      | `application/json`               |

即使页面生成的密钥看起来像十六进制，也将它作为**原文字符串**使用，不要先按 hex 解码。本合同不宣称兼容 Standard Webhooks SDK。

- 网关要求时间与当前时钟相差不超过 300 秒；发送端与接收端应校时。
- 同一次业务事件重试保留事件 ID 和原始正文；可以生成新的当前时间戳和签名。
- 同 ID、相同原始正文返回既有接纳结果；同 ID、不同正文返回 409。
- 先 JSON 解析再重新序列化，可能改变空格、字段顺序或编码；验签必须使用收到的原始字节。
- 入站请求体上限 1 MiB。新业务事件或新版本用新 ID，不用重试代替查询进度。

事件去重不代表跨系统 exactly-once。业务侧仍要保存唯一事件键，并核对不确定的执行或外部副作用。

## 配置出站结果回调

### 先允许目标域名

内置模式打开「外部接入设置 → 结果回调」，在「允许的回调域名」填写精确域名，例如 `hooks.example.com`，然后保存。多个域名用逗号分隔，不写协议、端口、路径或通配符。

![只填写允许回调的精确域名；空名单禁止所有回调，本地查询和 SSE 使用各自的鉴权。](https://onevium.com/docs/external-access/3cc390708/zh/10-callback-allowlist.png "只填写允许回调的精确域名；空名单禁止所有回调，本地查询和 SSE 使用各自的鉴权。")

**名单留空会禁止所有回调。** 即使域名在名单中，解析到内网、回环或保留 IP 也会被阻止。远端模式在服务器设置 `ONEVIUM_GATEWAY_CALLBACK_HOSTS`，见[远端网关](https://onevium.com/zh/docs/developers/remote-gateway)。

### 再创建回调

返回应用的「事件与回调」，点「添加回调」：

| 表单字段    | 本例填写                                           |
| ------- | ---------------------------------------------- |
| 此设备上的服务 | 选择目标应用在该设备的测试服务                                |
| 回调地址    | `https://hooks.example.com/onevium/events`     |
| 订阅事件    | `run.completed, run.failed, approval.required` |
| 启用      | 勾选                                             |

![选择回调地址和订阅事件；此配置接收选定设备上该应用的服务事件（示例）。](https://onevium.com/docs/external-access/3cc390708/zh/11-result-callback.png "选择回调地址和订阅事件；此配置接收选定设备上该应用的服务事件（示例）。")

保存并单独保存**回调签名密钥**。它不是前面的入站密钥。

这里的服务选择用于确定设备和应用：**回调接收该设备上这个应用全部服务的所选事件，不只限所选助手。** 接收端应根据自己保存的 `run_id/conversation_id` 业务映射识别工单，不假设事件正文带有 `subject` 或 `app_id`。

## 一个可复制的回调接收器

本例只验签并将事件保存到专用演示目录，**不会更新工单，也不是生产数据库实现**。真实业务应在同一个持久事务中写入事件唯一键和业务状态，提交成功后再返回 2xx。

把下列代码保存为 `callback-receiver.mjs`，在 `callback.env` 中设置 `ONEVIUM_CALLBACK_SECRET` 为刚才的回调密钥：

```javascript
import http from 'node:http';
import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { mkdir, writeFile, readFile, link, unlink } from 'node:fs/promises';
import path from 'node:path';

const secret = process.env.ONEVIUM_CALLBACK_SECRET;
if (!secret || secret.length < 32) throw new Error('Missing callback secret');
const inbox = path.resolve('callback-inbox');
await mkdir(inbox, { recursive: true, mode: 0o700 });
const subscribed = new Set(['run.completed', 'run.failed', 'approval.required']);
const reject = (status) => Object.assign(new Error('Request rejected'), { status });

async function storeOnce(eventId, raw) {
  const name = createHash('sha256').update(eventId).digest('hex');
  const destination = path.join(inbox, `${name}.json`);
  const temporary = path.join(inbox, `${randomUUID()}.tmp`);
  try {
    await writeFile(temporary, raw, { flag: 'wx', mode: 0o600 });
    try {
      await link(temporary, destination);
    } catch (error) {
      if (error.code !== 'EEXIST') throw error;
      if (!(await readFile(destination)).equals(raw)) throw reject(409);
    }
  } finally {
    await unlink(temporary).catch(() => {});
  }
}

const server = http.createServer(async (request, response) => {
  try {
    if (request.method !== 'POST' || request.url !== '/onevium/events') {
      throw reject(404);
    }
    const id = request.headers['webhook-id'];
    const timestamp = request.headers['webhook-timestamp'];
    const supplied = request.headers['webhook-signature'];
    if (typeof id !== 'string' || !/^[\x21-\x7e]{1,128}$/.test(id) ||
        typeof timestamp !== 'string' || !/^\d{10,13}$/.test(timestamp) ||
        Math.abs(Date.now() / 1000 - Number(timestamp)) > 300 ||
        typeof supplied !== 'string' || !/^v1,[a-f0-9]{64}$/.test(supplied)) {
      throw reject(401);
    }
    const chunks = [];
    let size = 0;
    for await (const chunk of request) {
      size += chunk.length;
      if (size > 1024 * 1024) throw reject(413);
      chunks.push(chunk);
    }
    const raw = Buffer.concat(chunks);
    const expected = createHmac('sha256', secret)
      .update(`${id}.${timestamp}.`).update(raw).digest();
    if (!timingSafeEqual(expected, Buffer.from(supplied.slice(3), 'hex'))) {
      throw reject(401);
    }
    let event;
    try {
      event = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw));
    } catch {
      throw reject(400);
    }
    if (!event || event.event_id !== id || !subscribed.has(event.type) ||
        typeof event.run_id !== 'string' || typeof event.conversation_id !== 'string') {
      throw reject(400);
    }
    await storeOnce(id, raw);
    console.log(JSON.stringify({ event_id: id, type: event.type, stored: true }));
    response.writeHead(204).end();
  } catch (error) {
    response.writeHead(error.status || 500, { 'Content-Type': 'text/plain' })
      .end('Request rejected');
  }
});
server.requestTimeout = 15_000;
server.headersTimeout = 10_000;
server.maxConnections = 32;
server.listen(8789, '127.0.0.1', () => console.log('Callback receiver: 127.0.0.1:8789'));
```

```bash
node --env-file=callback.env callback-receiver.mjs
```

它只监听服务器本机 `127.0.0.1:8789`。你需要在**同一服务器**配置有效的公网 HTTPS 反向代理，把 `https://hooks.example.com/onevium/events` 转发到这里，并保留原始请求体及三个 `webhook-*` 请求头。不把 `http://127.0.0.1:8789` 填进 Onevium 回调地址。

事件包含 `event_id`、`type`、`conversation_id`、`generation`、`seq`、`run_id`、`message_id` 和 `payload`。例如 `run.completed` 的结果在 `payload.result`；`run.failed` 带 `payload.error`。保存的业务关联用于把该 Run 对应回 `ticket-42`。

示例用事件 ID 的哈希作为文件名，重复的相同事件返回 204，不重复创建记录；同 ID 不同内容返回 409。签名验证发生在 JSON 解析之前。这里只做代码示例，不代表接收器或你的反代已实测通过。

## 检查结果与投递日志

1. 重新发送一个**新的**工单版本事件，保留其接纳回执。
2. 在 Onevium 应用下检查业务会话和 Run 的实际状态。
3. 若订阅了对应事件，去「回调投递」看状态、尝试次数与最后错误。
4. 在接收端核对保存的 `event_id/run_id` 与本次执行相符。`delivered` 只证明接收端返回 2xx；工单是否更新还要检查业务数据库。

不使用回调时，以相同 App 与主体调用 `GET /api/v1/runs/:run_id`，或订阅 `/api/v1/runs/:run_id/events`。持续会话与 SSE 恢复见[会话教程](https://onevium.com/zh/docs/developers/conversations)。关闭 SSE 只是停止观察，不是停止运行。

| 投递状态或响应                    | 含义与处理                        |
| -------------------------- | ---------------------------- |
| `pending`／`sending`        | 等待或正在投递；不要另建重复业务任务           |
| `delivered`                | 收到 2xx；再核对业务处理结果             |
| 超时、连接失败、408/425/429/5xx    | 自动重试；遵守有效 Retry-After        |
| 其他非 2xx，包括重定向              | 进入 `needs_action` 等待处理；不跟随跳转 |
| `expired`                  | 自动尝试或投递窗口已结束                 |
| `cancelled_config_changed` | 配置变更取消旧投递；不会把旧事件改发新地址        |

自动投递以 24 小时窗口、最多 8 次尝试为边界。修复接收端后，`expired`／`needs_action` 记录可用「重试投递」；旧配置已停用或正文已过保留期时不能强行重试。轮换密钥或改 URL 会创建新配置版本，应先核对仍在途的旧事件。

## 失败时先查哪一层

| 现象           | 先检查                                           |
| ------------ | --------------------------------------------- |
| 入站 401       | 密钥是否混用、秒级时间与时钟偏差、原始 body、签名格式                 |
| 入站 403       | Webhook 是否启用、事件类型是否精确匹配、应用是否允许执行              |
| 入站 404       | 完整 URL 与 integration ID；不要把助手 ID 当 Webhook ID |
| 入站 400／413   | 字段路径、数据类型、有效输入，以及 1 MiB 上限                    |
| 入站 409       | 是否复用 ID 却改变正文，或控制动作代际不符                       |
| 有 Run 没有回调记录 | 回调启用状态、订阅事件、应用和设备范围                           |
| 回调目标被拒绝      | 名单、HTTPS/443、DNS 解析地址、证书；空名单不是允许全部            |
| 接收器 401／409  | 回调密钥、原始字节，以及事件去重记录                            |

## 下一步

先完成一条入站事件与一次结果查询，再按需要加入公网回调。查看[身份与认证](https://onevium.com/zh/docs/developers/authentication)和[远端网关](https://onevium.com/zh/docs/developers/remote-gateway)，把真实业务账号、模型、TLS、断线和恢复验证单独记录。
