服务 Webhook 接收端指南
本文说明 bbserver 会向你的端点发送什么、secret 是怎么使用的,以及接收端应该校验什么。
bbserver 会发送什么
当服务 Webhook 触发时,bbserver 会发送一个 HTTPS POST 请求,包含:
- 一个 JSON 请求体
- 描述事件和投递信息的请求头
- 使用 webhook
secret计算出的 HMAC-SHA256 签名
这里的 secret 不是 用来加密 body 的,而是用来给请求签名,便于你的接收端校验来源和完整性。
请求头
当前出站请求头如下:
User-Agent: bbserver-service-webhook/1X-BB-Event: certificate.issuedX-BB-Webhook-Id: <webhook_id>X-BB-Delivery-Id: <delivery_id>X-BB-Cert-Id: <cert_id>X-BB-Issue-History-Id: <issue_history_id>X-BB-Timestamp: <unix_ms>X-BB-Signature: sha256=<hex_digest>
请求体
典型请求体:
{
"event": "certificate.issued",
"cert_id": 123,
"issue_history_id": 456,
"user_id": 789,
"ts_ms": 1700000000000,
"domain_name": "example.com"
}
字段说明:
event:当前事件 keycert_id:证书记录 idissue_history_id:本次成功签发对应的历史记录 iduser_id:该 webhook / 证书所属用户 idts_ms:入队时间,毫秒domain_name:若可用则为主域名;该字段可能缺失
secret 是怎么用的
bbserver 发送 webhook 时会计算:
signing_input = <X-BB-Timestamp> + "." + <raw_request_body>
signature = HMAC-SHA256(secret, signing_input)
然后通过下面的请求头发送:
X-BB-Signature: sha256=<hex_digest>
这意味着:
- body 是通过 HTTPS 发送的普通 JSON
- secret 是 bbserver 和你的接收端共享的密钥
- 你的接收端应当自行重新计算 HMAC 并比较
接收端校验步骤
建议你的接收端这样做:
- 按原样读取原始请求体字节。
- 读取
X-BB-Timestamp。 - 读取
X-BB-Signature。 - 按
<timestamp>.<raw_body>重建签名输入。 - 计算
HMAC-SHA256(secret, signing_input)。 - 将摘要转成十六进制,与去掉
sha256=前缀后的签名做比较。 - 校验时间窗口,例如超过 5 分钟则拒绝。
重要:
- 不要先 parse JSON 再 stringify 后再验签。
- 不要在验签前 trim 空白或规范化换行。
- 比较签名时请使用常量时间比较。
最小可行接收逻辑
如果你只想先做一个安全基线:
- 校验签名
- 校验时间窗口
- 检查
event === "certificate.issued" - 将真实业务处理异步化
- 尽快返回
204
Node.js 示例
下面的示例使用 Express,在解析 JSON 之前先基于原始请求体完成验签。
const crypto = require('crypto');
const express = require('express');
const app = express();
const webhookSecret = process.env.BB_WEBHOOK_SECRET;
app.post('/bb/webhook', express.raw({ type: '*/*' }), (req, res) => {
const timestamp = req.header('x-bb-timestamp');
const signatureHeader = req.header('x-bb-signature') || '';
if (!timestamp || !signatureHeader.startsWith('sha256=')) {
return res.status(400).send('missing signature headers');
}
const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
const signingInput = Buffer.concat([
Buffer.from(String(timestamp), 'utf8'),
Buffer.from('.', 'utf8'),
rawBody,
]);
const expected = crypto
.createHmac('sha256', webhookSecret)
.update(signingInput)
.digest('hex');
const provided = signatureHeader.slice('sha256='.length);
const expectedBuf = Buffer.from(expected, 'hex');
const providedBuf = Buffer.from(provided, 'hex');
if (expectedBuf.length !== providedBuf.length) {
return res.status(401).send('invalid signature');
}
if (!crypto.timingSafeEqual(expectedBuf, providedBuf)) {
return res.status(401).send('invalid signature');
}
const now = Date.now();
const skewMs = Math.abs(now - Number(timestamp));
if (!Number.isFinite(skewMs) || skewMs > 5 * 60 * 1000) {
return res.status(401).send('stale timestamp');
}
let payload;
try {
payload = JSON.parse(rawBody.toString('utf8'));
} catch {
return res.status(400).send('invalid json');
}
if (payload.event !== 'certificate.issued') {
return res.status(400).send('unexpected event');
}
console.log('verified webhook', {
cert_id: payload.cert_id,
issue_history_id: payload.issue_history_id,
user_id: payload.user_id,
domain_name: payload.domain_name,
});
return res.status(204).end();
});
app.listen(3000, () => {
console.log('listening on :3000');
});
说明:
- 这个路由要使用
express.raw()。如果先被 JSON 中间件读取并重写 body,验签就会失败。 secret不要写进源码,建议放在环境变量里。- 接收成功后尽快返回
204,慢操作交给队列或后台任务。
如果不验签会怎样
端点在“功能上”仍然能收到请求,看起来也能工作,但它会退化成一个未鉴权的公开 POST 入口。
如果不验签:
- 任何能访问该 URL 的人都可以伪造事件
- 无法保证 payload 完整性
- 也基本失去重放保护意义
响应建议
bbserver 会把任意 2xx 视为投递成功。
可重试响应包括:
4084295xx
建议接收端:
- 一旦请求被接受,尽快返回
204或其它2xx - 慢操作放到响应之后,通常交给异步任务队列
- 只有在确实是永久性问题时再返回
4xx,例如签名非法
验签后的后续处理
Webhook 是通知,不是证书包本身。验签成功后,请使用 cert_id 通过你现有的鉴权 API 流程去拉取当前证书内容。