Webhook 与结果回调:事件接入、验签和投递
配置入站业务事件与出站结果回调,复制验签示例,核对事件去重和真实投递结果。
开发者接入先区分进入与返回#
入站 Webhook:工单系统发生事件,向 Onevium 提交任务。结果回调:任务产生选定事件,Onevium 向你的接收服务投递结果。两者使用不同地址、不同签名密钥,可以单独启用。
下面用工单事件演示两条方向的配置和验证。截图来自真实预览组件和隔离演示数据,未发送真实业务请求。
准备一个完整测试场景#
先按外部接入创建应用,绑定助手,完成能力检查并发布服务、启用接入。本文用虚构工单 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 同样保留接收器路径。
业务链路如下:
- 工单更新 → 发送
ticket.updated。 - 网关按
ticket_id找到或创建业务会话,返回接纳回执。 - 业务后端保存
ticket-42与conversation_id/run_id的对应关系。 - 助手执行;业务后端通过查询、SSE 或可选回调取得结果。
本地业务系统可以直接查询或订阅 SSE。出站回调目前只支持管理员名单内的公网 HTTPS 地址,不支持 localhost、局域网或 IP 字面量,端口只能是默认 443。 不具备公网接收服务时,不必为了测试 API 开启回调。
配置入站 Webhook:点哪里、填什么#
打开「渠道 → 外部接入」,选中已创建的应用,在「事件与回调」点「添加 Webhook」。为本例填写:
Webhook 配置上半部分:选择服务、事件类型、业务主体和事件动作。
| 表单字段 | 本例值 | 含义 |
|---|---|---|
| 助手服务 | 已发布的测试助手 | 事件交给谁执行 |
| 事件类型字段 | type | 在 JSON 中读取事件类型的位置 |
| 事件类型 | ticket.updated | 精确允许值;多个值用逗号分隔 |
| 业务主体 | support-demo | 固定主体;不是 JSON 字段路径 |
| 事件动作 | 继续会话 | 对应 message |
| 消息字段 | text | 给助手的文字 |
| 结构化数据字段 | data | 本例为 JSON 对象 |
| 资源 ID 字段 | 留空 | 本例不传附件 |
| 会话匹配 | 业务会话键 | 使用业务标识关联上下文 |
| 会话键字段 | ticket_id | 同一工单继续同一业务会话 |
| 启用 | 勾选 | 允许接收事件 |
滚动后的字段近景:映射消息内容与业务会话标识,延续对应会话。
点「保存」,保存新生成的签名密钥。应用页面会显示完整地址:
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 与轮次的字段映射,以及任务运行时的处理策略。
文本、结构化数据和资源至少要提供一种有效输入。资源必须先经授权上传取得 ID,不要发送本机路径或让字段映射透传工具权限。
发送一个正确签名的事件#
在业务后端保存 ticket-event.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,把下面两个值替换成页面给出的值:
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 或以上版本,无需额外依赖:
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;
node --env-file=webhook.env send-ticket.mjs ticket-42-version-1
本例应取得 202 及 conversation_id、generation、message_id、run_id 等接纳信息;这些是预期结果,应以你实际响应为准。202 只表示持久接纳,不表示助手已完成。
签名、重放与重复事件#
入站与出站使用相同的签名格式,但密钥不同:
待签名字节 = 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 使用各自的鉴权。
名单留空会禁止所有回调。 即使域名在名单中,解析到内网、回环或保留 IP 也会被阻止。远端模式在服务器设置 ONEVIUM_GATEWAY_CALLBACK_HOSTS,见远端网关。
再创建回调
返回应用的「事件与回调」,点「添加回调」:
| 表单字段 | 本例填写 |
|---|---|
| 此设备上的服务 | 选择目标应用在该设备的测试服务 |
| 回调地址 | https://hooks.example.com/onevium/events |
| 订阅事件 | run.completed, run.failed, approval.required |
| 启用 | 勾选 |
选择回调地址和订阅事件;此配置接收选定设备上该应用的服务事件(示例)。
保存并单独保存回调签名密钥。它不是前面的入站密钥。
这里的服务选择用于确定设备和应用:回调接收该设备上这个应用全部服务的所选事件,不只限所选助手。 接收端应根据自己保存的 run_id/conversation_id 业务映射识别工单,不假设事件正文带有 subject 或 app_id。
一个可复制的回调接收器#
本例只验签并将事件保存到专用演示目录,不会更新工单,也不是生产数据库实现。真实业务应在同一个持久事务中写入事件唯一键和业务状态,提交成功后再返回 2xx。
把下列代码保存为 callback-receiver.mjs,在 callback.env 中设置 ONEVIUM_CALLBACK_SECRET 为刚才的回调密钥:
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'));
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 解析之前。这里只做代码示例,不代表接收器或你的反代已实测通过。
检查结果与投递日志#
- 重新发送一个新的工单版本事件,保留其接纳回执。
- 在 Onevium 应用下检查业务会话和 Run 的实际状态。
- 若订阅了对应事件,去「回调投递」看状态、尝试次数与最后错误。
- 在接收端核对保存的
event_id/run_id与本次执行相符。delivered只证明接收端返回 2xx;工单是否更新还要检查业务数据库。
不使用回调时,以相同 App 与主体调用 GET /api/v1/runs/:run_id,或订阅 /api/v1/runs/:run_id/events。持续会话与 SSE 恢复见会话教程。关闭 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 | 回调密钥、原始字节,以及事件去重记录 |
下一步#
先完成一条入站事件与一次结果查询,再按需要加入公网回调。查看身份与认证和远端网关,把真实业务账号、模型、TLS、断线和恢复验证单独记录。