Webhooks

在您的后端接收会话事件:每次投递都带 HMAC-SHA256 签名,失败按指数退避重试,并附带唯一 id,方便接收端做幂等处理。

什么时候用 webhook

用来对房间里发生的事作出反应,而不必轮询:会话开始或结束、一道问题关闭、结果定稿时,平台会主动调用您。投递是至少一次的,可能重复,但绝不会悄无声息地失败,所以接收端必须做到幂等。

事件目录

由 API 在 /public/v1/limits 提供,本页不留副本。

  • presentation.import.completed
  • presentation.import.failed
  • question.activated
  • question.closed
  • question.results.finalized
  • report.ready
  • session.ended
  • session.paused
  • session.resumed
  • session.started

每次投递都带的请求头

  • X-Votinova-Event事件类型,与目录中的写法完全一致。
  • X-Votinova-Delivery-Id每次投递唯一,也就是您的去重键。
  • X-Votinova-TimestampEpoch 秒;它在签名范围之内,因此重放的投递无法被改成新时间。
  • X-Votinova-Signature十六进制的 HMAC-SHA256,不带前缀。

请求体是紧凑的 JSON,包含事件和涉及的所有 id,下面这份就是那次带签名的示例投递,逐字节一致:

{"event":"session.ended","session_id":"68b2e3d4c5f607182930a5bc"}

校验签名

每次投递都用端点密钥,对时间戳、一个点号和请求体做 HMAC-SHA256 签名。把结果与 X-Votinova-Signature 请求头比对即可。

  • 请对原始请求体签名。解析后再序列化会改变空白字符,签名就对不上了。
  • 请用常数时间比较,不要用 ==。
已用真实投递验证过
const crypto = require("node:crypto");

function verify(headers, rawBody, secret) {
  const timestamp = headers["x-votinova-timestamp"];
  const signature = headers["x-votinova-signature"];
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
secret     whsec_3f8a1c92d4e5b6a7
timestamp  1785321600
body       {"event":"session.ended","session_id":"68b2e3d4c5f607182930a5bc"}

HMAC-SHA256(secret, timestamp + "." + body)
= 10a86171bd92f5fcf8c28b873978e0214302cc2291c54cc016d18347f3ee7c38

重试

Webhook 投递:最多尝试8次,等待间隔依次为21, 45, 95, 171, 369, 732, 1480秒。用尽之后投递会转入死信,可以手动重新投递。

幂等

请用 X-Votinova-Delivery-Id 作为去重键:同一次投递可能到达多次,而您的处理只应生效一次。

X-Votinova-Delivery-Id

最佳实践

  • 确认投递已落地后就立刻返回 200,具体处理放进自己的队列,接收端慢会让重试堆积起来。
  • 解析之前一定要先校验签名:否则,任何知道您 URL 的人都能伪造事件。
  • 用 X-Votinova-Delivery-Id 去重:至少一次意味着同一次投递可能调用两次。
  • 连续失败 10 次后,端点会自动暂停;接收端恢复后,在面板里或通过 API 重新启用它。