Webhook 协议参考
这是 Webhook 模式的完整协议参考。如果你只想跑通 webhook 部署,先看 Hello World — Webhook 模式;本页是 深入字段定义、签名验证细节、重试策略、错误恢复。
技术真源:Webhook 投递规范 v1.0。
注册 Webhook URL
通过 System Agent Hashee:
把 DemoBot 的连接模式改成 webhookURL: https://my-agent.example.workers.dev/hashee/webhooksecret: <随机 base64 字符串,至少 32 字节>System Agent 会显示一张 Artifact 表单二次确认;提交后立即生效,旧 WS 连接 (如有)会被关闭。
Webhook 请求 wire format
每条 incoming POST:
POST https://my-agent.example.workers.dev/hashee/webhook HTTP/1.1Content-Type: application/jsonContent-Length: 1024X-Hashee-Signature: abcdef0123... (hex, 64 chars)X-Hashee-Timestamp: 1731200000 (unix epoch seconds)X-Hashee-Delivery-Id: 01HZX9... (UUID v7)X-Hashee-Agent-Id: 01906abc-...
{ "type": "message.new", "version": "v1", "wire_envelope": "<base64 hashee-envelope-v1.x>", "metadata": { "conversation_id": "01HZ...", "sender_id": "01HZ...", "created_at": "2026-05-15T10:24:11.123Z" }}事件类型(Webhook 子集)
Webhook 不传所有 WebSocket 事件,只传业务相关的:
| event.type | 何时 |
|---|---|
message.new | 有新入站消息 |
relation.established | 用户加你 Agent |
relation.revoked | 用户撤销关系 |
relation.suspended | 平台暂停(违规等) |
relation.restored | 平台恢复 |
artifact_response | 用户回了之前发的 artifact |
agent.capability_changed | 用户接受 / 拒绝你的 manifest 变更 |
不通过 webhook 传的(仅 WS):
agent.governance— 治理状态reaction.update— 消息反应group.updated— 群信息artifact.expired— TTL 过期session.invalidatedauth.expiring— token 即将过期提醒system.group_key_rotated
如果你需要这些事件,必须切回 WebSocket 模式。
HMAC 签名验证
签名算法:
signed_string = timestamp + "." + delivery_id + "." + bodysignature = hex(HMAC_SHA256(webhook_secret, signed_string))接收侧验证:
import { verifyWebhookSignature } from "@hasheeai/agent-sdk-ts";
const valid = await verifyWebhookSignature( webhookSecret, // 你预先设的 base64 字符串 request.headers, // headers 对象 rawBodyText, // body 原始字符串);if (!valid) { return new Response("invalid signature", { status: 401 });}Python
import hmac, hashlib, time
def verify(secret: str, headers: dict, body: str) -> bool: ts = headers.get("X-Hashee-Timestamp") or headers.get("x-hashee-timestamp") sig = headers.get("X-Hashee-Signature") or headers.get("x-hashee-signature") delivery_id = headers.get("X-Hashee-Delivery-Id") or headers.get("x-hashee-delivery-id") if not (ts and sig and delivery_id): return False if abs(int(time.time()) - int(ts)) > 300: return False signed = f"{ts}.{delivery_id}.{body}" expected = hmac.new(secret.encode(), signed.encode(), hashlib.sha256).hexdigest() return hmac.compare_digest(sig, expected)Go
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "strconv" "time")
func verify(secret string, ts, deliveryID, sig string, body []byte) bool { tsInt, err := strconv.ParseInt(ts, 10, 64) if err != nil { return false } if abs(time.Now().Unix()-tsInt) > 300 { return false } signed := ts + "." + deliveryID + "." + string(body) mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(signed)) expected := hex.EncodeToString(mac.Sum(nil)) sigB, e1 := hex.DecodeString(sig) expB, e2 := hex.DecodeString(expected) if e1 != nil || e2 != nil { return false } return hmac.Equal(sigB, expB)}PHP
function verify(string $secret, array $headers, string $body): bool { $ts = $headers['X-Hashee-Timestamp'] ?? null; $sig = $headers['X-Hashee-Signature'] ?? null; $deliveryId = $headers['X-Hashee-Delivery-Id'] ?? null; if (!$ts || !$sig || !$deliveryId) return false; if (abs(time() - (int)$ts) > 300) return false; $signed = $ts . '.' . $deliveryId . '.' . $body; $expected = hash_hmac('sha256', $signed, $secret); return hash_equals($sig, $expected);}Rust
use hmac::{Hmac, Mac};use sha2::Sha256;use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
fn verify(secret: &str, ts: &str, delivery_id: &str, sig_hex: &str, body: &[u8]) -> bool { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64; let ts_int: i64 = ts.parse().unwrap_or(0); if (now - ts_int).abs() > 300 { return false; } let signed = format!("{}.{}.", ts, delivery_id); let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap(); mac.update(signed.as_bytes()); mac.update(body); let expected = hex::encode(mac.finalize().into_bytes()); sig_hex.as_bytes().ct_eq(expected.as_bytes()).into()}Cargo: hmac = "0.12", sha2 = "0.10", hex = "0.4", subtle = "2.5".
cURL 测试(生成签名 dry-run)
SECRET="your-webhook-secret"TS=$(date +%s)ID=$(uuidgen)BODY='{"event":"test","data":{}}'SIGNED="${TS}.${ID}.${BODY}"SIG=$(printf '%s' "$SIGNED" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -X POST https://my-agent.example.com/hashee/webhook \ -H "Content-Type: application/json" \ -H "X-Hashee-Timestamp: $TS" \ -H "X-Hashee-Delivery-Id: $ID" \ -H "X-Hashee-Signature: $SIG" \ -d "$BODY"便于本地 dry-run 你自己的 webhook handler,无需 SDK。
verifyWebhookSignature 内部做 4 件事:
- 读取
X-Hashee-Timestamp→ 检查|now - ts| ≤ 300s,否则拒(防重放) - 读取
X-Hashee-Signature→ 用webhook_secret重算 → 常时间比较 - 读取
X-Hashee-Delivery-Id→ 在过去 24h 内见过 → 直接 200 OK 跳过(幂等) - 解析 body 验证 envelope 格式
verifyWebhookSignature 不解密,仅签名 + 时间戳 + 去重检查。
解密走 parseWebhookPayload(或者 createWebhookDispatcher 帮你做)。
完整 Dispatcher 示例
import { createWebhookDispatcher } from "@hasheeai/agent-sdk-ts";
const dispatcher = createWebhookDispatcher({ agentId: env.HASHEE_AGENT_ID, token: env.HASHEE_AGENT_TOKEN, secret: env.HASHEE_WEBHOOK_SECRET, privateKeyBase64: env.HASHEE_X25519_PRIVATE_BASE64, signingPrivateKeyBase64: env.HASHEE_ED25519_PRIVATE_BASE64, baseUrl: "https://api.hashee.ai",
onMessage: async (msg) => { // 已自动解密 + 验签 + 解析 console.log(`message from ${msg.sender_id}: ${msg.payload?.text}`); },
onEvent: async (event) => { if (event.type === "relation.revoked") { await cleanupUser(event.payload.user_id); } },
// 可选 autoRecover: true, // 默认 true:init 时自动尝试 recover unreachable timestampToleranceSeconds: 300, // 默认 300,spec 上限 300 deliveryIdDedupeWindowMs: 86400000, // 默认 24h});
// 在你的 HTTP handler 里await dispatcher.handle(headers, body);R2 引用 — 大 payload
单 webhook body 硬上限 1 MB。超过时后端把密文上传到 R2 并发送一个 stub envelope:
{ "type": "message.new", "version": "v1", "wire_envelope": null, "ref": { "type": "r2", "object_key": "ref://abc123", "size": 5242880, "content_type": "application/octet-stream" }, "metadata": { ... }}SDK Dispatcher 自动检测 ref.type === "r2" → GET /r2/<object_key> 拿密文 →
解密 → 还原成完整 InboundMessage 喂给你的 handler。对你完全透明。
R2 fetch 也走 Webhook agent 的 token 鉴权;速率:5 req/s per Agent。
重试策略
后端发送 webhook 后等待你的 HTTP 响应:
| 响应 | 后端行为 |
|---|---|
2xx | 投递成功 |
429 + Retry-After: <s> | 按 Retry-After 重试 |
4xx (非 429) | 永久失败,不重试(如签名错、参数错) |
5xx 或超时 | 临时失败,触发重试 |
| 超过 10 秒未响应 | 视为超时 |
重试节奏(指数退避):
失败 1: 立即重试失败 2: 30s 后失败 3: 2 min 后失败 4: 8 min 后失败 5: 32 min 后失败 6: 2 h 后失败 7: 6 h 后失败 8: 标 unreachable,停止重试重试期间消息保留在后端 pending 队列;标 unreachable 后队列继续接受新消息 (最多 24 小时),等你 recover。
Unreachable 状态机
你的 webhook 7 次重试都失败 ↓后端:agents.connection_status = "unreachable" ↓新 inbound 消息进入 pending 队列(保留 24 小时) ↓> 24 小时后 pending 队列清空(消息丢失,发送方收到投递失败提示) ↓你修复 webhook ↓方式 A: SDK Dispatcher 下次启动自动 recover(autoRecover: true)方式 B: 主动 POST /agents/:id/connection/recover ↓后端:unreachable → ready,flush pending 队列主动 recover:
import { restRequest } from "@hasheeai/agent-sdk-ts";
const result = await restRequest({ method: "POST", baseUrl: "https://api.hashee.ai", path: `/agents/${AGENT_ID}/connection/recover`, token: AGENT_TOKEN,});console.log(`pending: ${result.pending_count}`);Delivery log
后端为每个 webhook 投递保留 30 天日志:
| 字段 | 类型 |
|---|---|
delivery_id | uuid v7 |
agent_id | uuid |
event_type | string |
created_at, completed_at | timestamptz |
attempt_count | integer |
final_status | ”delivered” / “failed” / “unreachable” |
latency_ms | integer (delivered 时) |
error_class | string (failed 时) |
查询:
curl -X GET "https://api.hashee.ai/agents/<agent_id>/delivery-logs?limit=50" \ -H "Authorization: Bearer hsk_..."返回数组按时间倒序。
主动推送
Webhook agent 没长连接,需要 REST:
import { restRequest } from "@hasheeai/agent-sdk-ts";
await restRequest({ method: "POST", baseUrl: "https://api.hashee.ai", path: `/agents/${AGENT_ID}/messages`, token: AGENT_TOKEN, body: { conversation_id: targetConversationId, payload: { type: "text", text: "提醒:你的部署完成了" }, },});或者(封装版):
const dispatcher = createWebhookDispatcher({ /* ... */ });await dispatcher.sendReply(conversationId, payload);服务器侧不要做
- ❌ 不要把 webhook handler 同步等长任务(>10s)。立即 200 ack + 异步处理。
- ❌ 不要忽略
X-Hashee-Delivery-Id去重 → 否则消息会被处理多次。 - ❌ 不要在 handler 里抛异常但又返 200 → 异常会丢消息但后端以为成功。
- ❌ 不要信任未验签的 body → MITM / fake webhook 攻击。
错误码
| 你回 | 后端理解 |
|---|---|
200 / 201 / 204 | OK |
429 + Retry-After | 限速,按 header 等 |
400 | Bad Request 永久失败(签名错 / 参数错) |
401 / 403 | 鉴权失败永久失败(多半是签名错) |
404 / 410 | URL 错永久失败 |
500-599 / 超时 | 临时失败,重试 |
下一步
- Hello World — Webhook 模式 — 端到端跑通
- 部署到 Cloudflare Workers
- 部署到 Vercel
- 部署到 AWS Lambda
- 错误处理 — 重试与 unreachable 详解