Webhook

Webhook

Webhook 让 Favonis 在任务停下时主动通知你的服务器:任务完成、被取消或暂停时,Favonis 向你设置的 HTTPS 地址发送一个 POST 请求,请求体里是这次事件和任务对象,请求头带着签名。接收方校验签名、返回 2xx,再根据任务状态处理,不必反复查询任务。

添加地址

在 Favonis 中打开 设置 → 开发者 → Webhook 添加地址,步骤见如何接收 Webhook 推送?

  • 地址必须以 https:// 开头,能从公网访问,不能带用户名和密码。
  • 添加时 Favonis 先推送一个 webhook.test 事件,10 秒内收到 2xx 响应才会保存。地址不符合上一条,或者已有 10 个地址时,设置页当即提示,不推送测试事件。
  • 保存后显示一次签名密钥,以 whsec_ 开头,用来校验请求确实来自 Favonis。
  • 你在每个空间中最多添加 10 个地址,每个地址都会收到全部事件。
  • 请求不跟随跳转,返回 3xx 按推送失败处理。

之后在设置中点击发送测试,会再推送一次 webhook.test。无论是否收到 2xx,结果都记在这个地址上,显示最近一次推送的时间和状态码,不影响地址的启用状态。

会推送哪些任务

地址按你在这个空间里的身份推送:个人空间里是你个人账号的任务,团队空间里只有你在该团队中发起的任务。团队所有者和管理员添加的地址,同样收不到其他成员的任务。

  • 用 API 密钥发起的一轮:新建任务,或用 API 发消息开始的新一轮。这一轮每次停下各推送一次,例如先暂停、继续后再完成,会推送两次。
  • 定时任务的每次运行,同样每次停下各推送一次。

在应用里发起的对话不推送;在应用里接着对话开始的新一轮,即使任务最初由 API 发起,也不推送。我的电脑中的本地对话和定时任务不在 API 的范围内,同样不推送。任务删除到回收站之后,不再推送它的事件。

事件

type 什么时候推送
task.completed 这一轮完成
task.cancelled 这一轮被取消
task.paused 中途暂停,原因见 data.task.reason
webhook.test 添加地址或在设置中发送测试时,不带任务

以后可能增加新的事件类型,请忽略不认识的 type,同样返回 2xx。

请求格式

POST /favonis/webhook HTTP/1.1
Content-Type: application/json
User-Agent: Favonis-Webhooks/1.0
webhook-id: evt_0196a3f9-8b14-7c2e-a5d0-6f1e9b3c7a42
webhook-timestamp: 1793957507
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{"id":"evt_0196a3f9-8b14-7c2e-a5d0-6f1e9b3c7a42","type":"task.completed","created_at":"2026-11-06T09:31:47Z","data":{"task":{...}}}

请求体的字段:

字段 说明
id 事件 ID,和请求头 webhook-id 相同,重试时不变
type 事件类型
created_at 事件发生的时间
data.task 任务对象,格式和 GET /v1/tasks/{id} 相同。status、reason 是事件发生那一刻的,其余内容在投递时按这次事件对应的那一轮读取;webhook.test 不含这一项

校验签名

签名遵循 Standard Webhooks 规范:

  • webhook-timestamp 是发送时的 Unix 时间戳(秒)。
  • 签名内容是 {webhook-id}.{webhook-timestamp}.{原始请求体},用 HMAC-SHA256 计算,密钥是签名密钥去掉 whsec_ 之后那段 Base64 解码得到的字节。
  • webhook-signature 是 v1, 加上签名的 Base64,可能有多个,以空格分隔,其中任意一个对上即为有效。

校验时要用收到的原始请求体,不能先解析成 JSON 再重新序列化。建议拒绝时间戳和当前时间相差超过 5 分钟的请求,防止重放。

Node.js

import crypto from "node:crypto";

const secret = Buffer.from(process.env.FAVONIS_WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64");

// rawBody 是原始请求体(Buffer),headers 的键是小写。
export function verifyFavonisWebhook(rawBody, headers) {
	const id = headers["webhook-id"];
	const timestamp = Number(headers["webhook-timestamp"]);
	const signatures = headers["webhook-signature"];
	if (!id || !timestamp || !signatures) return false;
	if (Math.abs(Date.now() / 1000 - timestamp) > 5 * 60) return false;

	const expected = crypto
		.createHmac("sha256", secret)
		.update(`${id}.${timestamp}.`)
		.update(rawBody)
		.digest();
	return signatures.split(" ").some((item) => {
		const [version, value] = item.split(",");
		const given = Buffer.from(value ?? "", "base64");
		return (
			version === "v1" &&
			given.length === expected.length &&
			crypto.timingSafeEqual(given, expected)
		);
	});
}

在 Express 中,用 express.raw 取得原始请求体,先返回 2xx,再交给后台处理:

app.post("/favonis/webhook", express.raw({ type: "application/json" }), (req, res) => {
	if (!verifyFavonisWebhook(req.body, req.headers)) return res.sendStatus(400);
	res.sendStatus(204);
	queue.push(JSON.parse(req.body)); // 你自己的任务队列
});

Python

import base64
import hashlib
import hmac
import os
import time

SECRET = base64.b64decode(os.environ["FAVONIS_WEBHOOK_SECRET"].removeprefix("whsec_"))


def verify_favonis_webhook(body: bytes, headers) -> bool:
    webhook_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp", "")
    signatures = headers.get("webhook-signature")
    if not (webhook_id and timestamp.isascii() and timestamp.isdigit() and signatures):
        return False
    if abs(time.time() - int(timestamp)) > 5 * 60:
        return False

    signed = f"{webhook_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(hmac.new(SECRET, signed, hashlib.sha256).digest())
    # 两边都按字节比较:请求头里混入非 ASCII 字符时,compare_digest 比较 str 会抛出 TypeError。
    return any(
        version == "v1" and hmac.compare_digest(value.encode(), expected)
        for version, _, value in (item.partition(",") for item in signatures.split())
    )

在 Flask 中,request.get_data() 是原始请求体,request.headers 不区分大小写:

@app.post("/favonis/webhook")
def favonis_webhook():
    if not verify_favonis_webhook(request.get_data(), request.headers):
        return "", 400
    queue.put(request.get_json())  # 你自己的任务队列
    return "", 204

使用官方库

也可以使用 Standard Webhooks 的官方库,它会检查时间戳并校验签名,失败时抛出异常:

import { Webhook } from "standardwebhooks"; // npm install standardwebhooks

const event = new Webhook(process.env.FAVONIS_WEBHOOK_SECRET).verify(rawBody, headers);
from standardwebhooks.webhooks import Webhook  # pip install standardwebhooks

event = Webhook(os.environ["FAVONIS_WEBHOOK_SECRET"]).verify(body, headers)

响应与重试

每次推送等待响应最多 10 秒。请先返回 2xx,再异步处理事件;超时或返回其他状态码都算推送失败。

  • 失败后依次间隔 1、2、4、8……256 分钟重试,连同第一次共推送 10 次,前后约 8.5 小时。
  • 每个事件至少送达一次,可能重复,也不保证顺序。用 webhook-id 去重;需要任务的最新状态时,以 GET /v1/tasks/{id} 为准。
  • 每次重试的 webhook-id 不变,webhook-timestamp 和签名按发送时重新生成。
  • 返回 410 Gone 表示这个地址不再接收,地址立即停用。
  • 10 次都未成功,地址同样会被停用。两种停用都会在 Favonis 通知的消息中提醒你。排除问题后,在设置中点击启用;停用期间的事件不会补发。
  • 在设置中更换签名密钥后,旧密钥立即失效。请尽快把新密钥更新到服务器上;更新之前签名校验不通过的推送会按上面的规则重试。