Stabo 开发者文档

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事件唯一 IDevt_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。验证步骤:

  1. 解析 Header,取出 t(时间戳,秒)和 v1(签名)。
  2. 校验时间戳,拒绝过期请求。
  3. 构造待签名字符串: eventId + "." + timestamp + "." + rawBody。eventId 取自 Webhook-Event-Id,rawBody 是未经解析的原始请求体。
  4. 用 Webhook 密钥对待签名字符串计算 HMAC-SHA256,结果转为小写十六进制字符串。
  5. 常数时间比较期望签名与 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()
    );
}

On this page