跳到主要内容

服务 Webhook 接收端指南

本文说明 bbserver 会向你的端点发送什么、secret 是怎么使用的,以及接收端应该校验什么。

bbserver 会发送什么

当服务 Webhook 触发时,bbserver 会发送一个 HTTPS POST 请求,包含:

  • 一个 JSON 请求体
  • 描述事件和投递信息的请求头
  • 使用 webhook secret 计算出的 HMAC-SHA256 签名

这里的 secret 不是 用来加密 body 的,而是用来给请求签名,便于你的接收端校验来源和完整性。

请求头

当前出站请求头如下:

  • User-Agent: bbserver-service-webhook/1
  • X-BB-Event: certificate.issued
  • X-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:当前事件 key
  • cert_id:证书记录 id
  • issue_history_id:本次成功签发对应的历史记录 id
  • user_id:该 webhook / 证书所属用户 id
  • ts_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 并比较

接收端校验步骤

建议你的接收端这样做:

  1. 按原样读取原始请求体字节。
  2. 读取 X-BB-Timestamp
  3. 读取 X-BB-Signature
  4. <timestamp>.<raw_body> 重建签名输入。
  5. 计算 HMAC-SHA256(secret, signing_input)
  6. 将摘要转成十六进制,与去掉 sha256= 前缀后的签名做比较。
  7. 校验时间窗口,例如超过 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 视为投递成功。

可重试响应包括:

  • 408
  • 429
  • 5xx

建议接收端:

  • 一旦请求被接受,尽快返回 204 或其它 2xx
  • 慢操作放到响应之后,通常交给异步任务队列
  • 只有在确实是永久性问题时再返回 4xx,例如签名非法

验签后的后续处理

Webhook 是通知,不是证书包本身。验签成功后,请使用 cert_id 通过你现有的鉴权 API 流程去拉取当前证书内容。

相关文档