外发 Webhook

外发 Webhook 会在项目发生变化时,向另一个系统发送一个带签名的 HTTPS 请求:工作项(Issue)被创建、更新、评论或删除,或维基(Wiki)页面被保存。可以用它向聊天频道推送消息、触发构建,或让其他工具与 Specivo 保持同步。无论改动来自 Web 界面、REST API,还是 AI 智能体(AI agent)通过 MCP 做出的,发送的事件都相同。

创建 Webhook

你需要在项目中拥有 管理项目设置 权限。打开项目的 设置 → Webhook 标签页,点击 新建 Webhook:

  • Payload URL:请求的发送地址。必须以 https:// 开头,且不能指向私有网络(见 Specivo 拒绝的地址)。
  • 事件:从下列事件中选择一个或多个。对话框和投递日志按标签显示事件:工作项已创建、工作项已更新、工作项有新评论、工作项已删除、维基页面已更新,以及对应 ping 的 测试事件。
  • 包含私有工作项和私有备注:仅管理员可见;见私有内容。

保存后,Specivo 会显示一次 签名密钥。把它复制到接收方系统中,之后不会再显示。要更换密钥,用 编辑 打开该 Webhook 并点击 生成新密钥;旧密钥会立即失效。

Webhook 标签页

每个 Webhook 都有以下操作:投递日志、发送测试(发送一个 ping 事件,对已停用的 Webhook 也有效)、停用 / 启用、编辑 和 删除(同时删除其投递日志)。

REST API 在 /api/v1/projects/{key}/webhooks/ 下提供相同的功能,所需权限也相同。通过 API 还可以在创建或更新时用 secret 设置自己的密钥(16 到 100 个字符),而不使用自动生成的密钥;rotate_secret: true 则生成一个新密钥。只有在刚设置了密钥时,响应中才会包含它。

事件

事件 发送时机
issue.created 创建工作项时。
issue.updated 工作项的字段被修改,或在更新时添加了备注。添加或删除附件和关联,以及由仓库集成填写的 commits、branches 和 pull_requests 元数据,都不发送事件。
issue.commented 工作项收到评论时。
issue.deleted 工作项被删除时。
wiki.page.updated 创建维基页面时,以及每次保存该页面的新版本时。Specivo 自动创建的 首页 不发送事件。
ping 你点击 发送测试 时。此事件无法订阅。

事件只在改动保存成功后发送:失败并回滚的改动不发送任何事件。通过导入带入的数据也不发送事件。

Payload

每个请求都是 POST,JSON 请求体的结构如下:

字段 内容
event 事件名称,见上文。
delivery_id 投递 ID,一个 UUID;与 X-Specivo-Delivery 请求头相同。
occurred_at 改动发生的时间,UTC。
project 项目的 key 和 name。
actor 做出改动的人(或智能体账户)的 id、login 和 display_name;如果改动由 Specivo 自动完成(例如重复任务替换一个重复项),则为 null。
data 事件数据,见下文。

各事件的 data:

事件 data
issue.created issue:工作项,字段与 REST API 的工作项对象相同(即 GET /api/v1/issues/{key}/ 的返回,不含子工作项和其他附加内容)。
issue.updated issue;changes:每个被修改的字段及其 old 和 new 值;journal_id:本次更新写入的历史记录条目;notes:随更新添加的备注,没有则为 null。
issue.commented issue;comment:评论,格式与工作项的 journal API 返回的相同。
issue.deleted issue:工作项被删除前一刻的状态。
wiki.page.updated page:页面及其正文;comments:编辑摘要,没有则为 null;created:新建页面时为 true。
ping webhook_id 和 message。

在 changes 中,工作项字段按 API 的命名(status_id、assigned_to_id、subject 等),值按工作项历史记录的存储方式给出:ID 为字符串,description 为完整文本。

示例:工作项事件

issue.updated,工作项被设为 In Progress(进行中)并指派后:

{
  "event": "issue.updated",
  "delivery_id": "075a0356-82da-473f-80ab-0e0c10f6dce5",
  "occurred_at": "2026-10-30T22:04:28.014132Z",
  "project": {"key": "ACME", "name": "Acme Website"},
  "actor": {"id": 4, "login": "alex", "display_name": "Alex Example"},
  "data": {
    "issue": {
      "id": 1,
      "key": "ACME-1",
      "project_key": "ACME",
      "subject": "Add the login form",
      "description": null,
      "tracker": {"id": 2, "name": "Feature"},
      "status": {"id": 2, "name": "In Progress"},
      "priority": {"id": 2, "name": "Normal"},
      "category": null,
      "author": {"id": 4, "name": "Alex Example"},
      "assigned_to": {"id": 4, "name": "Alex Example"},
      "fixed_version": null,
      "fixed_version_id": null,
      "parent_id": null,
      "root_id": 1,
      "lft": 1,
      "rgt": 2,
      "sprint_id": null,
      "start_date": null,
      "due_date": null,
      "done_ratio": 0,
      "estimated_hours": null,
      "original_estimate": null,
      "remaining_estimate": null,
      "is_private": false,
      "metadata": {
        "commits": [
          "25031421b3a7f0640165a835b52ef48e76f49e1f",
          "826674632415a1a29012a1c1c594cf4ba01a2e17"
        ],
        "branches": ["feat/ACME-1-add-the-login-form"],
        "pull_requests": ["https://github.example.com/example-org/website/pull/14"]
      },
      "lock_version": 5,
      "created_at": "2026-10-30T22:02:55.722698Z",
      "updated_at": "2026-10-30T22:04:27.751427Z"
    },
    "changes": {
      "status_id": {"old": "1", "new": "2"},
      "assigned_to_id": {"old": null, "new": "4"}
    },
    "journal_id": 6,
    "notes": null
  }
}

issue.created 和 issue.deleted 只包含 issue。issue.commented 包含 issue 和 comment:

"comment": {
  "id": 7,
  "sequence": 6,
  "issue_id": 1,
  "user": {"id": 4, "name": "Alex Example"},
  "notes": "Ready for review.",
  "details": [],
  "is_private": false,
  "reply_to_id": null,
  "is_resolved": false,
  "resolved_at": null,
  "resolved_by": null,
  "resolved_summary": null,
  "created_at": "2026-10-30T22:04:28.056324Z",
  "updated_at": "2026-10-30T22:04:28.056324Z"
}

示例:维基事件

{
  "event": "wiki.page.updated",
  "delivery_id": "b50a35ae-d784-4f03-9a08-9a3a256efe77",
  "occurred_at": "2026-10-30T22:04:28.328618Z",
  "project": {"key": "ACME", "name": "Acme Website"},
  "actor": {"id": 4, "login": "alex", "display_name": "Alex Example"},
  "data": {
    "page": {
      "id": 1,
      "slug": "release-checklist",
      "title": "Release checklist",
      "text": "1. Tag the release",
      "parent_id": null,
      "protected": false,
      "lock_version": 1,
      "content_version": 1,
      "content_author": {"id": 4, "name": "Alex Example"},
      "content_updated_at": "2026-10-30T22:04:28.269006Z",
      "created_at": "2026-10-30T22:04:28.269006Z",
      "updated_at": "2026-10-30T22:04:28.269006Z"
    },
    "comments": "First draft",
    "created": true
  }
}

示例:测试事件

{
  "event": "ping",
  "delivery_id": "3354d211-011b-44af-a802-906c38bc9827",
  "occurred_at": "2026-10-30T22:04:27.653584Z",
  "project": {"key": "ACME", "name": "Acme Website"},
  "actor": {"id": 4, "login": "alex", "display_name": "Alex Example"},
  "data": {"webhook_id": 2, "message": "Test event sent from Specivo."}
}

工作项和页面对象遵循 REST API,因此今后可能新增字段。只读取你需要的字段,忽略其余字段即可。

请求头与签名

请求头 值
Content-Type application/json;请求体为 UTF-8。
User-Agent Specivo-Webhook/1
X-Specivo-Event 事件名称,例如 issue.updated。
X-Specivo-Delivery 投递 ID,与请求体中的 delivery_id 相同。
X-Specivo-Signature sha256= 加上请求体的十六进制 HMAC-SHA256,以签名密钥为键。

要确认请求确实来自 Specivo 且在传输途中未被篡改,请对收到的 原始请求体字节(在任何 JSON 解析之前)计算 HMAC,再用常量时间比较与请求头中的值对比。先解析 JSON 再重新序列化会改变字节,签名就对不上了。

Python:

import hashlib
import hmac


def is_valid(secret: str, body: bytes, signature: str | None) -> bool:
    """True when *signature* (the X-Specivo-Signature header) matches *body*."""
    expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), (signature or "").encode())

Flask 中原始请求体是 request.get_data();FastAPI 或 Starlette 中是 await request.body()。

Node.js:

const crypto = require("node:crypto");

function isValid(secret, body, signature) {
  // body: the raw request body as a Buffer, before any JSON parsing
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(body).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

使用 Express 时,在 Webhook 路由上用 express.raw({ type: "application/json" }) 读取请求体,这样 req.body 就是 Buffer。

签名无效时返回错误,不要做任何其他处理。

投递、重试与重复

投递在后台发送,每条投递逐次进行,在改动保存后不久即发出。收到 2xx 响应即视为投递成功。其他情况:

结果 处理方式
连接错误、超时、主机名无法解析、5xx、408 或 429 稍后重试。
重定向(3xx)或其他任何 4xx 立即失败,不再重试。永远不会跟随重定向。
Specivo 拒绝的地址、无效端口、无法再读取的密钥,或 Specivo 内部的意外错误 立即失败,不发送。

一条投递最多尝试 8 次。第 n 次重试等待 30 秒 × 2n−1,最长一小时,再加上最多四分之一的随机时长,以便把重试分散开:大约在上一次尝试后 30 秒、1、2、4、8、16 和 32 分钟。第八次尝试后,该投递标记为 失败。每次尝试中,请求的每一步(连接、发送、读取)最多 30 秒,总共最多 60 秒。Specivo 最多保留响应的前 4096 字节,用于日志。

同一条投递的重试带有 相同 的 X-Specivo-Delivery ID 和相同的请求体。一条投递可能到达多次(例如响应太慢被 Specivo 算作超时),所以请用该 ID 识别已处理过的投递。投递也可能乱序到达;如果顺序很重要,请使用 occurred_at,或从 API 读取当前状态。

如果改动保存时任务队列不可用,投递会以 等待中 状态等待,队列恢复后再发送:不会丢失。这种情况下预计会延迟约五分钟或更久,因为 Specivo 要先确认原来的尝试没有还在运行。

已停用的 Webhook 不会收到任何内容:停用时仍在等待的投递会以“Webhook 已停用,未发送。”失败。发送测试 对已停用的 Webhook 也有效,可以在启用前先检查接收方。

Specivo 拒绝的地址

每次尝试都会先解析主机名,只要其中任何一个地址是私有地址、回环地址、链路本地地址或其他非公网地址,就拒绝发送。之后它连接到检查过的那个地址。保存 Webhook 时,直接写有此类地址的 URL 会被拒绝。因此 Webhook 无法用来访问你内网中的机器,即使其 DNS 记录之后发生变化也一样。Specivo 环境中的代理设置不用于 Webhook。

私有内容

默认情况下,Webhook 不会收到任何关于 私有工作项(完全没有事件,包括对它们的评论和删除)或 私有备注 的内容。把工作项改为私有或改为公开的更新也算作私有。

只有 管理员 可以开启 包含私有工作项和私有备注,因为只有管理员能看到所有私有工作项。非管理员的项目经理无法创建这样的 Webhook,也无法编辑、停用、启用已开启此选项的 Webhook 或为其更换密钥。他们仍能在列表中看到它(标记为 包含私有内容),查看其投递日志、向它发送测试事件以及删除它。维基事件永远不是私有的。

投递日志

Webhook 所在行的 投递日志 按从新到旧列出其投递:

列 显示内容
时间 事件创建的时间。悬停可查看投递 ID。
事件 事件。
状态 等待中(等待首次尝试或重试)、成功 或 失败。
尝试次数 到目前为止的尝试次数。
响应代码 最后一次响应的 HTTP 状态码;没有响应时为 –。
响应 最后一次尝试失败的原因,以及接收方响应的开头部分。

某个 Webhook 的投递日志

响应 栏可能出现的消息:

消息 含义
接收方返回 HTTP 代码。 接收方未返回 2xx。
N 秒后超时。 / 超时:60 秒内未收到完整响应。 接收方太慢。请尽快响应,之后再处理工作。
连接失败:错误。 无法连接到接收方。错误 是失败类型,例如 ConnectError。
无法解析主机 名称。 主机名(暂时)无法解析。会重试。
主机解析为私有、回环或保留地址,未发送。 见 Specivo 拒绝的地址。
Webhook URL 的端口无效,未发送。 修正 URL。
Webhook 已停用,未发送。 投递发送前该 Webhook 已被停用。
无法用当前 SECRET_KEY 读取 Webhook 密钥,请设置新密钥。 实例的 SECRET_KEY 已更改。请生成新密钥。
投递失败:错误。 Specivo 内部的意外错误;错误 是错误类型。不会重试,请告知管理员。

成功和失败的投递会在 30 天后删除。等待中的投递会保留到有结果为止。REST API 通过 GET /api/v1/projects/{key}/webhooks/{id}/deliveries/ 返回同样的日志,POST /api/v1/projects/{key}/webhooks/{id}/test/ 发送测试事件。

每步超时由管理员通过 WEBHOOK_TIMEOUT 设置;见配置。

如果想反过来接收来自代码托管平台的事件,见仓库集成。