ROMP Agent Webhook
Agent Webhook 用于将 ROMP 会话消息自动推送到外部服务。当会话中有 Agent 参与者,且非 Agent 发送者发送消息时,系统自动向 Agent 配置的 Webhook URL 发送 POST 请求。
触发条件
- Agent 的
can_receive设置为启用 - 发送者不是 Agent(
auth.method !== 'agent') - 非静默上下文消息
- fire-and-forget,不阻塞消息发送
- 进程内去重(5 分钟内同一 message_id 不重复触发,best-effort)
幂等处理
去重机制是进程内 best-effort,不是强保证。接收端应以 message.id 做幂等处理,避免重复消费。
请求格式
HTTP 方法
POST
请求体
json
{
"event": "message.created",
"conversation_id": "550e8400-e29b-41d4-a716-446655440000",
"message": {
"id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"sender_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"parts": [
{ "kind": "text", "text": "Hello" }
]
},
"agent_user_id": "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11",
"timestamp": "2026-03-17T08:00:00.000Z"
}字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
event | string | 固定为 message.created |
conversation_id | UUID | 会话 ID |
message.id | UUID | 消息 ID(用于幂等处理) |
message.sender_id | UUID | 发送者用户 ID |
message.parts | Part[] | 消息内容,按 kind 区分类型 |
agent_user_id | UUID | 目标 Agent 的用户 ID |
timestamp | ISO 8601 | 触发时间戳 |
Part 类型
| kind | 字段 | 说明 |
|---|---|---|
text | text | 文本消息内容 |
thinking | content | 思考过程内容 |
| 其他 | 各异 | 前向兼容,建议忽略未知 kind |
请求头
| Header | 来源 | 可覆盖 | 说明 |
|---|---|---|---|
Content-Type | 系统 | 否 | 固定为 application/json |
X-Webhook-Signature | 系统 | 否 | HMAC-SHA256 签名(仅配置了 Webhook Secret 时存在) |
| 自定义 Headers | 用户配置 | — | 用户在 Agent 设置中添加的自定义请求头 |
自定义 Headers 限制:
- 最多 20 个
- Key 必须符合 RFC 7230 token 格式(系统会自动规范化为小写)
- 不能覆盖系统头(
Content-Type、X-Webhook-Signature等) - 不能使用
connection、host、keep-alive等受限 header
响应语义
Webhook 采用 fire-and-forget 模式:
- 响应体被忽略,不影响消息发送流程
- 2xx 状态码视为成功,清除
last_error - 非 2xx / 超时 / 网络错误 → 写入
last_error状态(可在 Agent 管理页面查看)
签名验证(Webhook Secret)
配置 Webhook Secret 后,系统会使用 HMAC-SHA256 对请求体进行签名,签名值放在 X-Webhook-Signature header 中。
签名算法
signature = HMAC-SHA256(webhook_secret, raw_request_body).hex()关键:签名基于原始请求体(raw bytes),不是解析后重新序列化的 JSON。
Node.js 验证示例
javascript
import crypto from 'crypto'
import { timingSafeEqual } from 'crypto'
// Express 中间件 — 需要 raw body
// app.use('/webhook', express.raw({ type: 'application/json' }))
function verifyWebhookSignature(rawBody, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody) // 必须是原始 bytes,不是 JSON.parse 后再 stringify
.digest('hex')
// 使用 timing-safe 比较,防止时序攻击
const sig = Buffer.from(signature, 'utf8')
const exp = Buffer.from(expected, 'utf8')
if (sig.length !== exp.length) return false
return timingSafeEqual(sig, exp)
}
// Express 路由
app.post('/webhook', (req, res) => {
const signature = req.headers['x-webhook-signature']
if (!signature) {
return res.status(401).send('Missing signature')
}
if (!verifyWebhookSignature(req.body, signature, WEBHOOK_SECRET)) {
return res.status(403).send('Invalid signature')
}
const payload = JSON.parse(req.body.toString())
console.log('Received webhook:', payload.event, payload.message.id)
// 幂等处理:检查 message.id 是否已处理过
// ...
res.status(200).send('OK')
})Python 验证示例
python
import hmac
import hashlib
from flask import Flask, request
app = Flask(__name__)
WEBHOOK_SECRET = 'your-webhook-secret'
@app.route('/webhook', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-Webhook-Signature')
if not signature:
return 'Missing signature', 401
# 使用原始请求体计算签名
expected = hmac.new(
WEBHOOK_SECRET.encode(),
request.get_data(), # 原始 bytes,不是 request.json
hashlib.sha256
).hexdigest()
# 使用 timing-safe 比较
if not hmac.compare_digest(signature, expected):
return 'Invalid signature', 403
payload = request.get_json()
print(f"Received webhook: {payload['event']} {payload['message']['id']}")
# 幂等处理:检查 message.id 是否已处理过
# ...
return 'OK', 200自定义 Headers vs Webhook Secret
| 自定义 Headers | Webhook Secret | |
|---|---|---|
| 用途 | 身份认证(证明"我有权访问") | 完整性验证(证明"请求未被篡改且来自外脑") |
| 内容 | 固定 key-value(如 Authorization: Bearer xxx) | HMAC 签名(动态,每次请求不同) |
| 典型场景 | 调用需要 API Key 的第三方接口 | 验证请求来源可信、防中间人篡改 |
| 是否必须 | 可选 | 可选 |
建议: 如果你的接收端是公网可访问的,建议同时使用两者 — Custom Headers 用于鉴权,Webhook Secret 用于验证请求完整性。
安全特性
| 特性 | 说明 |
|---|---|
| HTTPS 强制 | Webhook URL 必须以 https:// 开头 |
| SSRF 防护 | 双重校验:字符串层(禁止私有 IP 格式)+ DNS 解析层(实际解析后再校验) |
| DNS 固定 | 域名解析后固定到已验证的 IP 发送请求,消除 TOCTOU(检查时和使用时不一致)窗口 |
| 请求超时 | 10 秒超时,防止慢响应拖垮系统 |
| 禁止重定向 | redirect: 'error',防止通过重定向绕过 SSRF 防护 |
| Header 注入防护 | RFC 7230 格式校验,禁止换行符和受限 header |