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 通知的消息中提醒你。排除问题后,在设置中点击启用;停用期间的事件不会补发。
- 在设置中更换签名密钥后,旧密钥立即失效。请尽快把新密钥更新到服务器上;更新之前签名校验不通过的推送会按上面的规则重试。

