Webhook
通知机制、请求头、事件结构和签名验证。
Stabo 通过 Webhook 主动推送事件通知到您的服务器(如收款成功)。这使您无需轮询即可实时获知账户变动。
所有 Webhook 请求都包含 HMAC-SHA256 签名。您必须验证签名以确保请求来自 Stabo,防止伪造攻击。详见下文签名验证。
通知机制
- 协议: HTTPS (TLS 1.2+)
- 请求方式: HTTP POST
- 请求地址: 开发者在后台配置的 Webhook URL(必须使用 HTTPS)
- 内容类型: application/json
- 签名算法: HMAC-SHA256
- 重试策略: 如果响应状态码不是 2xx,Stabo 将进行指数退避重试,最多重试 5 次
- 重试间隔: 1分钟、5分钟、30分钟、2小时、6小时
- 超时时间: 30 秒(建议在 30 秒内返回响应)
最佳实践: 收到 Webhook 通知后,建议先返回 2xx 响应,再异步处理业务逻辑,避免处理超时导致重复推送。
请求头
Stabo 发送的每个 Webhook 请求都包含以下 HTTP Header:
| Header | 说明 | 示例 |
|---|---|---|
Content-Type | 请求内容类型 | application/json |
Webhook-Event-Id | 事件唯一 ID | evt_1234567890 |
Webhook-Event-Type | 事件类型 | checkout.payment_intent.succeeded |
Webhook-Signature | 签名信息(用于验证请求真实性) | t=1678888888,v1=a3f7c8b9... |
事件对象结构
{
"id": "evt_1Mxxx",
"object": "event",
"type": "checkout.payment_intent.succeeded",
"created": 1678888888,
"data": {
"object": {}
}
}字段说明:
id: 事件唯一标识符object: 固定值"event"type: 事件类型(如checkout.payment_intent.succeeded)created: 事件创建时间戳(秒)data.object: 事件数据载荷(根据事件类型不同而变化)
各产品的事件类型和载荷见产品指南,例如 Checkout 的 Webhook 事件。
签名验证
为了确保 Webhook 请求来自 Stabo 而非第三方伪造,您必须验证每个请求的签名。
Webhook-Signature 的格式为 t=timestamp,v1=signature。验证步骤:
- 解析 Header,取出
t(时间戳,秒)和v1(签名)。 - 校验时间戳,拒绝过期请求。
- 构造待签名字符串:
eventId + "." + timestamp + "." + rawBody。eventId取自Webhook-Event-Id,rawBody是未经解析的原始请求体。 - 用 Webhook 密钥对待签名字符串计算 HMAC-SHA256,结果转为小写十六进制字符串。
- 常数时间比较期望签名与
v1。
验证代码示例
Java (Spring Boot)
private boolean verifySignature(String signatureHeader, String eventId,
String rawBody, String secret) throws Exception {
// 1. 解析签名 Header (格式: t=timestamp,v1=signature)
String[] parts = signatureHeader.split(",");
long timestamp = 0;
String signature = null;
for (String part : parts) {
String[] kv = part.split("=", 2);
if ("t".equals(kv[0])) {
timestamp = Long.parseLong(kv[1]);
} else if ("v1".equals(kv[0])) {
signature = kv[1];
}
}
if (timestamp == 0 || signature == null) {
throw new IllegalArgumentException("Invalid signature header");
}
// 2. 验证时间戳
long currentTime = System.currentTimeMillis() / 1000;
if (Math.abs(currentTime - timestamp) > TIMESTAMP_TOLERANCE) {
throw new IllegalArgumentException("Request expired");
}
// 3. 构造待签名字符串
String signedPayload = eventId + "." + timestamp + "." + rawBody;
// 4. 计算期望签名
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKey = new SecretKeySpec(
secret.getBytes(StandardCharsets.UTF_8),
"HmacSHA256"
);
mac.init(secretKey);
byte[] hash = mac.doFinal(signedPayload.getBytes(StandardCharsets.UTF_8));
String expectedSignature = HexFormat.of().formatHex(hash);
// 5. 比对签名(常数时间比较)
return MessageDigest.isEqual(
signature.getBytes(),
expectedSignature.getBytes()
);
}