Webhook
对于您订阅的每个信封事件,Bona Fide 都会向您的端点发送一个 HTTPS POST 请求。投递遵循 Standard Webhooks 规范,因此可以用该规范的任意一个库来验证。
注册端点
章节标题“注册端点”在 设置 › Webhook 中注册,或使用具有 org:manage 权限范围的 API 密钥注册:
curl https://api.bonafidesign.com/v1/webhook-endpoints \ -H "Authorization: Bearer $BONA_FIDE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://app.example.com/hooks/bona-fide", "description": "Production", "events": ["envelope.completed", "envelope.sealed", "envelope.declined"] }'响应中包含 secret(whsec_…),仅此一次。events 列表为空表示订阅所有事件。URL 必须是公网可访问的 https:// 地址。端点只属于一种模式:使用测试密钥创建的端点只接收测试模式信封的事件,正式端点只接收正式事件。
PATCH /v1/webhook-endpoints/{id} 可更改 URL、事件或描述,也可暂停或恢复端点(enabled)。POST /v1/webhook-endpoints/{id}/test 会发送一个 webhook.test 测试请求。
事件
章节标题“事件”| 类型 | 触发时机 |
|---|---|
envelope.created |
信封已创建 |
envelope.ready |
文档已渲染,信封已准备好交给收件人 |
recipient.sent |
轮到某位收件人(如果是电子邮件投递,其链接已发送) |
recipient.viewed |
某位收件人首次打开签署页面 |
recipient.signed |
某位收件人已提交(或已被自动会签) |
recipient.declined |
某位收件人拒绝签署 |
envelope.completed |
所有收件人均已完成。data.values 包含变量和所有填写的值 |
envelope.sealed |
已封存的 PDF 可供下载 |
envelope.declined |
由于有收件人拒绝签署,信封已终止 |
envelope.voided |
您已作废该信封 |
envelope.expired |
在完成之前已超过 expiresAt |
signing_link.used |
有访问者通过常设签署链接发起了信封 |
acceptance.created 已在架构中预留,但目前尚不会发出。
负载
章节标题“负载”{ "id": "evt_9sKq2Lm4Tz", "type": "envelope.completed", "createdAt": "2026-10-13T14:09:52.004Z", "testMode": false, "data": { "envelope": { "id": "env_8Gm2kQ4rT1", "status": "completed", "seal": "pending", "recipients": [{ "id": "rcp_5Jw0pLs9Xe", "role": "client", "status": "completed", "…": "…" }], "externalId": "user_8841", "metadata": {}, "…": "…" }, "values": { "variables": { "client": { "name": "Acme Robotics, Inc." }, "effective_date": "2026-10-13" }, "recipients": { "client": { "client_title": "CEO", "start_date": "2026-11-01" } } } }}data.envelope 是事件发生时完整的信封。data.values 仅出现在 envelope.completed 中:包括您发送的变量,以及按字段 ID 列出的每个角色的字段值。
请求头与签名
章节标题“请求头与签名”每次投递都带有:
| 请求头 | 值 |
|---|---|
webhook-id |
事件 ID(evt_…);每次重试都相同。可用于去重 |
webhook-timestamp |
本次尝试签名时的 Unix 时间(秒) |
webhook-signature |
v1,<base64 HMAC-SHA256>,当有多个密钥签名时以空格分隔 |
user-agent |
BonaFide-Webhooks/1 |
签名是对 {webhook-id}.{webhook-timestamp}.{raw body} 计算的 HMAC-SHA256,其密钥为密钥字符串中 whsec_ 之后部分经 base64 解码的结果。请验证原始请求体(在 JSON 解析之前),使用恒定时间比较,并拒绝与您的时钟相差超过五分钟的时间戳。
import express from 'express';import { Webhook } from 'standardwebhooks'; // npm i standardwebhooks
const wh = new Webhook(process.env.BONA_FIDE_WEBHOOK_SECRET!); // whsec_…const app = express();
app.post('/hooks/bona-fide', express.raw({ type: 'application/json' }), (req, res) => { let event; try { event = wh.verify(req.body, req.headers as Record<string, string>); // throws if forged or too old } catch { return res.sendStatus(400); } queue.add(event); // do the work asynchronously and answer quickly res.sendStatus(204);});# pip install standardwebhooks flaskimport osfrom flask import Flask, requestfrom standardwebhooks.webhooks import Webhook
wh = Webhook(os.environ["BONA_FIDE_WEBHOOK_SECRET"]) # whsec_…app = Flask(__name__)
@app.post("/hooks/bona-fide")def bona_fide(): try: event = wh.verify(request.get_data(), dict(request.headers)) except Exception: return "", 400 enqueue(event) return "", 204import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(secret: string, headers: Record<string, string>, rawBody: string) { const id = headers['webhook-id']; const ts = Number(headers['webhook-timestamp']); if (!id || !ts || Math.abs(Date.now() / 1000 - ts) > 300) throw new Error('stale or missing headers'); const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const expected = createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest(); const ok = (headers['webhook-signature'] ?? '').split(' ').some((part) => { const [version, sig] = part.split(','); const given = Buffer.from(sig ?? '', 'base64'); return version === 'v1' && given.length === expected.length && timingSafeEqual(given, expected); }); if (!ok) throw new Error('bad signature'); return JSON.parse(rawBody);}重试
章节标题“重试”在 10 秒内返回任意 2xx 即视为投递成功。不会跟随重定向。其他任何情况都会按退避策略重试,分别在 5 秒、5 分钟、30 分钟、2 小时、5 小时、10 小时、14 小时、20 小时和 24 小时后进行,最长持续至事件发生后三天。之后,该投递会被标记为失败。
- 快速响应并异步处理;根据
webhook-id去重。 - 投递可能乱序到达:请检查
data.envelope.status,而不要假定事件的先后顺序。 GET /v1/webhook-endpoints/{id}/deliveries列出最近的投递尝试及其状态码、错误和耗时,每个端点还会显示其 24 小时成功率(health)。POST /v1/events/{id}/redeliver会将某个事件重新发送到端点,例如在服务中断之后。
轮换密钥
章节标题“轮换密钥”POST /v1/webhook-endpoints/{id}/secret/rotate 会立即返回一个新密钥。在接下来的 24 小时内,每次投递都会同时使用新密钥和旧密钥签名(webhook-signature 中有两个条目),这样您就可以在不丢失事件的情况下部署新密钥。