WebHook
本文档说明 Poco 中 WebHook 的触发时机、签名机制与 Python 接收端实现示例。
1. 触发时机与范围
- 事件类型:
session.completed:对话执行完成(对应前端run.done)session.failed:对话执行失败session.stopped:对话被用户停止scheduled_task.run_status_changed:定时任务运行状态变化user_input.requested:执行过程请求用户输入user_input.answered:用户完成输入回答workbench.insight_status_changed:工作台洞察状态变化publish_page.published:发布中心成功生成一个新的发布页版本publish_page.failed:发布页自动发布失败workspace_export.completed:工作区文件导出完成,可读取文件清单与归档信息- 生效范围:
global:所有事件project:仅指定项目下的事件(优先匹配事件关联的session.project_id)
2. 配置项
在设置页 WebHook 中可配置:
- 名称
- 推送地址(HTTP/HTTPS)
- 订阅事件(见上方事件类型)
- 生效范围(全局/指定项目)
- 会话类型过滤(仅对话类事件生效,包括
session.*与workspace_export.completed,可选chat/api/scheduled) - 签名密钥(可选)
- 超时(毫秒)
- 重试次数
- 重试退避(毫秒)
管理员可在 WebHook 页面切换到“系统 WebHook”,或通过 GET /api/v1/webhooks?owner_scope=system 管理系统级 WebHook。系统级 WebHook 接收所有用户的匹配事件,仅支持全局生效范围;请求体中的 user 字段表示事件归属用户,webhook.owner_scope 表示本次投递来自用户级还是系统级 WebHook。
3. HTTP 请求格式
3.1 请求头
Content-Type: application/jsonUser-Agent: Poco-Webhook/1.0X-Poco-Event: <event_type>X-Poco-Event-Id: <event_id>X-Poco-Timestamp: <ISO8601 时间>X-Poco-Signature: sha256=<hex>(仅配置了签名密钥时发送)
3.2 对话完成请求体(示例)
{
"schema_version": "v1",
"event_type": "session.completed",
"event_id": "2d3134c5-...",
"occurred_at": "2026-02-15T14:30:00+00:00",
"is_test": false,
"user": {
"user_id": "1234567890",
"username": "alice",
"full_name": "Alice",
"department": "研发",
"role": "user"
},
"webhook": {
"id": 12,
"owner_scope": "system"
},
"session": {
"session_id": "5f6a...",
"title": "日报总结",
"kind": "chat",
"status": "completed",
"project_id": "97d2...",
"created_at": "2026-02-15T14:20:00+00:00",
"updated_at": "2026-02-15T14:30:00+00:00"
},
"run": {
"run_id": "6dc9...",
"turn_id": "20cf...",
"status": "completed",
"schedule_mode": "immediate",
"scheduled_task_id": null,
"started_at": "2026-02-15T14:20:10+00:00",
"finished_at": "2026-02-15T14:29:58+00:00"
},
"summary": {
"has_artifacts": true,
"has_assistant_response": true,
"message_count": 9,
"assistant_message_count": 4,
"tool_execution_count": 3,
"total_cost_usd": 0.012345,
"input_tokens": 2345,
"output_tokens": 1234,
"duration_ms": 588000
},
"meta": {
"trigger": "run.done",
"source": "poco.backend.callback.completed",
"idempotency_key": "2d3134c5-...",
"capability_profile_version": 3,
"model_stop_reason": "end_turn"
}
}3.3 发布页新增版本请求体(示例)
{
"schema_version": "v1",
"event_type": "publish_page.published",
"event_id": "7ec2d1a1-...",
"occurred_at": "2026-02-15T14:35:00+00:00",
"is_test": false,
"user": {
"user_id": "1234567890",
"username": "alice",
"full_name": "Alice",
"department": "研发",
"role": "user"
},
"webhook": {
"id": 13,
"owner_scope": "user"
},
"publish_page": {
"publish_center_id": "8c01...",
"name": "日报发布",
"publish_key": "daily-report",
"source_type": "scheduled_task",
"category": "日报",
"public_to_board_enabled": true
},
"version": {
"version_no": 3,
"session_id": "5f6a...",
"entry_path": "/report.html",
"share_code": "AbC123xYz0",
"published_url": "/share/p/daily-report",
"trigger_type": "auto",
"reason": "AI selected the first publishable report"
},
"session": {
"session_id": "5f6a...",
"title": "日报总结",
"kind": "scheduled",
"project_id": "97d2..."
},
"meta": {
"trigger": "publish_page.published",
"source": "poco.backend.publish_center",
"idempotency_key": "7ec2d1a1-..."
}
}3.4 其他事件请求体字段
以下事件均保持统一外层结构:schema_version、event_type、event_id、occurred_at、is_test、user、webhook、meta。接收端建议优先用 event_id 或 meta.idempotency_key 做幂等处理。user 表示事件归属用户,系统级 WebHook 可用该字段区分不同用户的事件;webhook.id 表示本次投递使用的 WebHook 配置 ID,webhook.owner_scope 为 user 或 system。
| 事件 | 主要字段 | 说明 |
|---|---|---|
session.failed | session、run、meta.error_message | 对话失败时推送会话、运行、错误信息 |
session.stopped | session、run、meta.error_message | 用户停止对话时推送会话、运行、停止原因 |
scheduled_task.run_status_changed | scheduled_task、run、session | 定时任务运行状态从 run.previous_status 变为 run.status 时推送 |
user_input.requested | user_input、session | 请求用户输入时推送 tool_name、tool_input、过期时间 |
user_input.answered | user_input、session | 用户回答后推送 answers 与 answered_at |
workbench.insight_status_changed | insight、meta.trigger_source | 工作台洞察状态变化时推送标题、摘要、优先级、前后状态 |
publish_page.failed | publish_page、job、error、session | 发布页自动发布失败时推送发布页、任务、错误信息 |
workspace_export.completed | workspace_export、session、run | 工作区文件导出完成时推送清单、归档和关联会话信息 |
session.failed / session.stopped 的 session、run 字段与 session.completed 基本一致,但 status 分别为 failed / stopped,并会在 run.last_error 或 meta.error_message 中携带错误或停止原因。
scheduled_task.run_status_changed 的核心字段:
| 字段 | 说明 |
|---|---|
scheduled_task.scheduled_task_id | 定时任务 ID |
scheduled_task.name | 定时任务名称 |
scheduled_task.cron | Cron 表达式 |
scheduled_task.timezone | 定时任务时区 |
run.run_id | 本次运行 ID |
run.session_id | 本次运行关联会话 ID |
run.status | 当前运行状态 |
run.previous_status | 上一个运行状态,可能为 null |
run.last_error | 失败原因,可能为 null |
session.project_id | 关联项目 ID,可能为 null |
user_input.requested / user_input.answered 的核心字段:
| 字段 | 说明 |
|---|---|
user_input.request_id | 用户输入请求 ID |
user_input.session_id | 关联会话 ID |
user_input.tool_name | 发起请求的工具名 |
user_input.tool_input | 请求输入时的工具参数 |
user_input.status | 请求状态,通常为 pending 或 answered |
user_input.answers | 用户回答内容,未回答时为 null |
user_input.expires_at | 请求过期时间 |
user_input.answered_at | 回答时间,未回答时为 null |
publish_page.failed 的核心字段:
| 字段 | 说明 |
|---|---|
publish_page.publish_center_id | 发布中心 ID |
publish_page.name | 发布页名称 |
publish_page.publish_key | 发布页公开访问 key |
job.job_id | 自动发布任务 ID,可能为 null |
job.run_id | 关联运行 ID,可能为 null |
job.scheduled_task_id | 关联定时任务 ID,可能为 null |
job.status | 发布任务状态 |
job.attempt_count | 已尝试次数 |
job.max_attempts | 最大尝试次数 |
job.last_error | 发布任务最后一次错误 |
error.message | 本次失败错误信息 |
workspace_export.completed 的请求体示例:
{
"schema_version": "v1",
"event_type": "workspace_export.completed",
"event_id": "f1b5a6d4-...",
"occurred_at": "2026-02-15T14:32:00+00:00",
"is_test": false,
"user": {
"user_id": "1234567890",
"username": "alice",
"full_name": "Alice",
"department": "研发",
"role": "user"
},
"webhook": {
"id": 14,
"owner_scope": "user"
},
"workspace_export": {
"status": "ready",
"files_prefix": "workspaces/user-1/session/files",
"manifest_key": "workspaces/user-1/session/manifest.json",
"archive_key": "workspaces/user-1/session/archive.zip",
"archive_available": true
},
"session": {
"session_id": "5f6a...",
"title": "日报总结",
"kind": "chat",
"status": "completed",
"project_id": "97d2...",
"created_at": "2026-02-15T14:20:00+00:00",
"updated_at": "2026-02-15T14:32:00+00:00"
},
"run": {
"run_id": "6dc9...",
"turn_id": "20cf...",
"status": "completed",
"schedule_mode": "immediate",
"scheduled_task_id": null,
"started_at": "2026-02-15T14:20:10+00:00",
"finished_at": "2026-02-15T14:29:58+00:00"
},
"meta": {
"trigger": "workspace_export.completed",
"source": "poco.backend.callback.workspace_export",
"idempotency_key": "f1b5a6d4-...",
"capability_profile_version": 3
}
}workspace_export.completed 的核心字段:
| 字段 | 说明 |
|---|---|
workspace_export.status | 导出状态,当前完成事件固定为 ready |
workspace_export.files_prefix | 工作区文件对象前缀,可能为 null |
workspace_export.manifest_key | 工作区文件清单对象 key,可能为 null |
workspace_export.archive_key | 工作区归档包对象 key,可能为 null |
workspace_export.archive_available | 是否存在归档包 |
session.session_id | 关联会话 ID |
session.kind | 关联会话类型,chat / api / scheduled |
session.project_id | 关联项目 ID,可能为 null |
run.run_id | 关联运行 ID,未解析到运行时为 null |
run.turn_id | 关联轮次 ID,未解析到运行时为 null |
run.schedule_mode | 运行调度模式,可能为 null |
run.scheduled_task_id | 关联定时任务 ID,可能为 null |
meta.idempotency_key | 幂等 key,当前与 event_id 一致 |
该事件只暴露后端资源引用,不直接生成公开访问链接;接收端如果需要下载或读取工作区文件,应结合 API 访问能力使用这些 key 换取文件内容。
3.5 发布页事件可用数据
WebHook 接收端可直接从 publish_page.published 请求体拿到以下数据:
| 字段 | 说明 |
|---|---|
event_id | 事件 ID,可用于幂等处理 |
occurred_at | 事件发生时间,对应本次版本发布时间 |
publish_page.publish_center_id | 发布中心 ID |
publish_page.name | 发布页名称 |
publish_page.publish_key | 发布页公开访问 key |
publish_page.source_type | 发布来源类型,scheduled_task 或 existing_session |
publish_page.category | 发布页分类,可能为 null |
publish_page.public_to_board_enabled | 是否公开到发布广场 |
version.version_no | 本次发布版本号 |
version.session_id | 本次发布关联的会话 ID |
version.entry_path | 本次发布入口文件路径 |
version.share_code | 本次发布版本的分享码 |
version.published_url | 发布页访问路径 |
version.trigger_type | 发布触发方式,manual 或 auto |
version.reason | 发布原因,可能为 null |
session.session_id | 关联会话 ID |
session.title | 关联会话标题 |
session.kind | 关联会话类型 |
session.project_id | 关联项目 ID,可能为 null |
meta.idempotency_key | 幂等 key,当前与 event_id 一致 |
publish_page.published 事件本身只携带发布页标识、版本标识和关联会话信息;如果接收端需要展示或读取发布文件内容,可以使用事件中的 publish_page.publish_key、version.version_no 或 version.share_code 调用公开接口。
公开接口响应均使用统一响应结构,以下字段位于 data。
GET /api/v1/public/publishes/{publish_key} 可拿到当前发布页数据:
| 字段 | 说明 |
|---|---|
publish_key | 发布页公开访问 key,对应 /share/p/{publish_key} |
name | 发布页名称 |
description | 发布页描述,可能为 null |
category | 发布页分类,可能为 null |
origin | 发布来源标识 |
tags | 发布页标签数组 |
public_to_board_enabled | 是否公开到发布广场 |
latest_share_code | 最新版本的分享码 |
latest_entry_path | 最新版本入口文件路径 |
latest_published_at | 最新发布时间 |
distraction_free_mode_enabled | 是否开启沉浸阅读模式 |
share_type | 分享资源类型 |
workspace_file | 最新版本入口文件,可能为 null |
workspace_file 字段结构:
| 字段 | 说明 |
|---|---|
name | 文件名 |
path | 文件路径 |
mimeType | 文件 MIME 类型,可能为 null |
url | 文件预览地址 |
downloadUrl | 文件下载地址 |
GET /api/v1/public/publishes/{publish_key}/versions 可拿到发布页版本列表,支持 limit、offset、version_no、date、date_from、date_to、tz 查询参数:
| 字段 | 说明 |
|---|---|
version_no | 版本号 |
share_code | 当前版本分享码 |
entry_path | 当前版本入口文件路径 |
published_at | 当前版本发布时间,可能为 null |
is_latest | 是否为最新版本 |
GET /api/v1/public/publishes/{publish_key}/versions/{version_no}/files 可拿到指定版本的文件列表,返回 FileNode[]:
| 字段 | 说明 |
|---|---|
id | 文件节点 ID |
name | 文件或文件夹名称 |
type | 节点类型,file 或 folder |
path | 节点路径 |
children | 子节点列表,文件节点通常为 null |
url | 预览地址,文件节点可能存在 |
downloadUrl | 下载地址,文件节点可能存在 |
mimeType | MIME 类型,文件节点可能存在 |
oss_status | 对象存储状态,可能为 null |
oss_meta | 对象存储元数据,可能为 null |
如果需要按 version.share_code 读取事件对应版本的入口文件,可调用 GET /api/v1/public/shares/{share_code},其中 workspace_file 与上方结构一致。
3.6 测试推送排查技巧(推荐带 session_id)
为避免出现“测试推送成功,但真实任务完成后收不到”的误判,建议测试时带上真实会话 session_id,让后端按真实过滤条件做匹配校验(enabled / event_types / session_kinds / scope / project_id)。
推荐操作:
- 进入目标会话页面(URL 形如
/chat/{session_id})。 - 打开设置 -> WebHook -> 点击“测试推送”。
- 系统会自动携带当前
session_id发起测试。
也可以直接调用接口:
curl -X POST 'http://<backend>/api/v1/webhooks/<webhook_id>/test' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{"event_type":"session.completed","session_id":"<session_uuid>"}'测试发布页事件:
curl -X POST 'http://<backend>/api/v1/webhooks/<webhook_id>/test' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{"event_type":"publish_page.published","session_id":"<session_uuid>"}'测试工作区文件导出完成事件:
curl -X POST 'http://<backend>/api/v1/webhooks/<webhook_id>/test' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{"event_type":"workspace_export.completed","session_id":"<session_uuid>"}'返回字段说明(data):
delivered: 是否成功发到目标 URL(HTTP 2xx)matched: 是否通过了 WebHook 过滤规则filtered_reason: 未匹配时的原因(匹配成功时为null)tested_session_id: 本次用于匹配的会话 IDtested_session_kind: 本次会话类型(chat/api/scheduled)tested_project_id: 本次会话所属项目 ID(可能为null)
常见 filtered_reason:
event_type_filtered:WebHook 没有订阅本次测试事件session_kind_filtered:会话类型不在 WebHook 勾选范围内session_project_missing:WebHook 是项目级,但该会话没有项目project_mismatch:会话项目与 WebHook 指定项目不一致disabled:该 WebHook 已禁用
说明:
- 不传
session_id时,测试会使用默认上下文(kind=chat、project_id=null),仅用于连通性验证,不代表真实任务完成场景一定可达。
4. 签名机制与 Python 示例
签名算法:
- 取请求头
X-Poco-Timestamp,记为timestamp - 取 HTTP 原始请求体字节,记为
body_bytes - 拼接:
timestamp + "." + body_bytes - 使用 HMAC-SHA256(密钥为你的 WebHook 密钥)
- 十六进制输出后加前缀:
sha256=<hexdigest>
4.1 Python 生成签名(演示)
import hashlib
import hmac
def build_signature(secret: str, timestamp: str, body_bytes: bytes) -> str:
signing_payload = timestamp.encode("utf-8") + b"." + body_bytes
digest = hmac.new(
secret.encode("utf-8"),
signing_payload,
hashlib.sha256,
).hexdigest()
return f"sha256={digest}"4.2 Python 验签(推荐)
import hashlib
import hmac
def verify_signature(
secret: str,
timestamp: str,
body_bytes: bytes,
signature_from_header: str,
) -> bool:
expected = build_signature(secret, timestamp, body_bytes)
return hmac.compare_digest(expected, signature_from_header or "")5. Python 完整接收端示例(FastAPI)
示例包含:验签、时间窗口校验、幂等去重(内存版)、事件分发。
from __future__ import annotations
import hashlib
import hmac
import json
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
# 请改成你自己的密钥;如果你在 Poco 中没配置密钥,可关闭验签逻辑。
WEBHOOK_SECRET = "replace-with-your-secret"
# 简单幂等缓存(生产建议换 Redis/DB,并设置过期时间)
PROCESSED_EVENT_IDS: set[str] = set()
def build_signature(secret: str, timestamp: str, body_bytes: bytes) -> str:
signing_payload = timestamp.encode("utf-8") + b"." + body_bytes
digest = hmac.new(
secret.encode("utf-8"),
signing_payload,
hashlib.sha256,
).hexdigest()
return f"sha256={digest}"
def verify_signature(secret: str, timestamp: str, body_bytes: bytes, signature: str) -> bool:
expected = build_signature(secret, timestamp, body_bytes)
return hmac.compare_digest(expected, signature or "")
def verify_timestamp(timestamp: str, max_skew_minutes: int = 5) -> None:
try:
ts = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid X-Poco-Timestamp") from exc
now = datetime.now(timezone.utc)
if abs(now - ts.astimezone(timezone.utc)) > timedelta(minutes=max_skew_minutes):
raise HTTPException(status_code=400, detail="Timestamp skew too large")
@app.post("/webhook/poco")
async def receive_poco_webhook(
request: Request,
x_poco_event: str | None = Header(default=None, alias="X-Poco-Event"),
x_poco_event_id: str | None = Header(default=None, alias="X-Poco-Event-Id"),
x_poco_timestamp: str | None = Header(default=None, alias="X-Poco-Timestamp"),
x_poco_signature: str | None = Header(default=None, alias="X-Poco-Signature"),
) -> dict[str, Any]:
if not x_poco_event or not x_poco_event_id or not x_poco_timestamp:
raise HTTPException(status_code=400, detail="Missing webhook headers")
body_bytes = await request.body()
# 如果设置了密钥,执行验签
if WEBHOOK_SECRET:
if not x_poco_signature:
raise HTTPException(status_code=401, detail="Missing signature")
verify_timestamp(x_poco_timestamp, max_skew_minutes=5)
if not verify_signature(WEBHOOK_SECRET, x_poco_timestamp, body_bytes, x_poco_signature):
raise HTTPException(status_code=401, detail="Invalid signature")
# 解析 JSON
try:
payload = json.loads(body_bytes.decode("utf-8"))
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail="Invalid JSON payload") from exc
# 幂等处理
if x_poco_event_id in PROCESSED_EVENT_IDS:
return {"ok": True, "duplicate": True}
PROCESSED_EVENT_IDS.add(x_poco_event_id)
# 事件路由
if x_poco_event == "session.completed":
session_id = payload.get("session", {}).get("session_id")
title = payload.get("session", {}).get("title")
kind = payload.get("session", {}).get("kind")
has_artifacts = payload.get("summary", {}).get("has_artifacts")
# TODO: 在这里写你的业务逻辑(入库、推送消息、触发任务等)
print(
"[Webhook] session.completed",
f"session_id={session_id}",
f"title={title}",
f"kind={kind}",
f"has_artifacts={has_artifacts}",
)
elif x_poco_event == "publish_page.published":
publish_page = payload.get("publish_page", {})
version = payload.get("version", {})
print(
"[Webhook] publish_page.published",
f"publish_key={publish_page.get('publish_key')}",
f"version_no={version.get('version_no')}",
f"entry_path={version.get('entry_path')}",
)
else:
print("[Webhook] ignored event:", x_poco_event)
return {"ok": True}运行:
pip install fastapi uvicorn
uvicorn webhook_receiver:app --host 0.0.0.0 --port 8000 --reload