> For the complete documentation index, see [llms.txt](https://docs.catfee.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.catfee.io/getting-started/webhook/signature.md).

# 回调签名与验签

使用用户级 HMAC-SHA256 签名验证 CatFee Webhook 来源与完整性，包含用户中心设置、快速接入示例、防重放与幂等处理。

开启回调签名后，CatFee 会为你的 HTTP/HTTPS 回调附加 HMAC-SHA256 签名。你的服务器使用专属密钥验签，确认消息来自持有该密钥的一方，并且请求体和事件请求头没有被修改。

**每个用户只有一个密钥和一个开关**，统一作用于该用户所有 HTTP/HTTPS 回调。默认关闭；首次开启生成密钥，关闭后保留，重新开启仍使用原密钥。Telegram 和邮件通知不受影响。

{% hint style="info" %}
签名功能不改变通知 JSON 结构，`delegation_type` 等字段的兼容行为保持不变。事件协议仍通过 `X-EVENT-VERSION: 20260930` 标识；签名中的 `v1` 是独立的签名格式版本。事件字段说明见[回调（Webhook）](/getting-started/webhook.md)。
{% endhint %}

## 快速接入

1. 按下方示例准备接收接口：先获取原始请求体，验签通过后再解析 JSON。
2. 在用户中心开启回调签名，复制当前用户的签名密钥（下文记为 `secret`），保存到接收端的环境变量或密钥存储中。
3. 使用下方固定测试向量校验实现，再发送测试通知，确认实际回调能通过验签。
4. 接入持久化幂等和业务处理；可靠接收成功后返回 `HTTP 200`，响应正文为 `success`。

首次开启立即影响后续发送，包含已经排队的重试通知。请安排好开启、配置密钥和部署验签代码的切换窗口。切换过程中验签失败应返回非 2xx，由平台按现有策略重试；不要临时放行无签名或签名错误的请求。

## 签名的开启、查询与关闭

签名的开启、查询和关闭均在**用户中心**操作。

### 开启签名并获取密钥

1. 登录用户中心，进入回调签名设置。
2. 开启签名开关；首次开启时，系统会为当前用户生成一个签名密钥。
3. 复制签名密钥，保存到接收端的环境变量或密钥存储中，例如 `CATFEE_WEBHOOK_SECRET`。

下文和验签代码中的 `secret` 均指从用户中心复制的签名密钥，其格式为标准 Base64 编码的 32 字节随机密钥。它与购买能量 API 的 API Secret 不同，请勿混用，也不要放进前端代码、日志或公开仓库。

### 查询当前设置

在用户中心的回调签名设置中查看当前开关状态和签名密钥。从未开启时，尚未生成密钥；首次开启后，关闭开关仍会保留密钥。

### 关闭签名

在用户中心关闭签名开关。关闭后，后续 HTTP/HTTPS 回调不再附加签名；重新开启仍使用原密钥。

开关统一作用于当前用户的所有 HTTP/HTTPS 回调，并在每次实际发送时生效，**同样适用于测试通知和已有通知的重试**。关闭签名时，应同步调整自己的接收策略。

## 请求头与签名规则

开启签名后，在原有事件请求头基础上增加两个头：

| 请求头                 | 说明                                       |
| ------------------- | ---------------------------------------- |
| `X-EVENT-ID`        | 事件 ID，参与签名和幂等处理；重试时不变                    |
| `X-EVENT-TYPE`      | 事件类型，例如 `EVENT_DELEGATION`               |
| `X-EVENT-VERSION`   | 业务事件协议版本，当前为 `20260930`                  |
| `X-EVENT-TIMESTAMP` | **新增**，本次发送的 Unix 秒级时间戳，十进制字符串           |
| `X-EVENT-SIGNATURE` | **新增**，`v1=` 加 64 位小写十六进制 HMAC-SHA256 结果 |

请求头名称不区分大小写，**参与签名的值不能改写**。不需要传递密钥 ID，也不要把密钥放进回调请求。

### 计算步骤

1. 将从用户中心复制的 `secret` 按**标准 Base64**解码为 32 字节密钥，不要直接把 Base64 字符串当作 HMAC 密钥。
2. 按以下顺序拼接前缀，再追加收到的原始请求体字节：

```
prefix = "v1\n"
       + X-EVENT-TIMESTAMP + "\n"
       + X-EVENT-ID + "\n"
       + X-EVENT-TYPE + "\n"
       + X-EVENT-VERSION + "\n"

signed_bytes = UTF-8(prefix) + raw_body_bytes
signature = "v1=" + lowercase_hex(HMAC-SHA256(secret_bytes, signed_bytes))
```

3. 使用恒定时间比较函数，将计算结果与收到的签名比较。示例分别使用 `timingSafeEqual`、`compare_digest` 和 `MessageDigest.isEqual`。

分隔符 `\n` 是一个 LF 字节（`0x0a`），不是反斜杠加字母 `n`，也不是 CRLF。前缀字段为非空可打印 ASCII。最后一个前缀字段后有一个 LF，**body 后不额外添加换行**。

{% hint style="warning" %}
必须使用原始请求体验签，不能先解析 JSON 再重新序列化；字段顺序、空格、Unicode 转义或换行发生变化都会导致验签失败。

签名中的事件 ID 取自 **`X-EVENT-ID` 请求头**。JSON 中的 `event_id` 可能带有 `D-`、`B-` 等前缀，不能用它替代请求头 ID。
{% endhint %}

## 接收端示例

选择所用语言，按下方说明保存代码并配置 `CATFEE_WEBHOOK_SECRET`。示例已包含签名格式检查、300 秒时间窗口和恒定时间比较；**你的业务幂等及可靠落库仍需自行实现**。代码按文件分为两个标签，可使用代码块的复制按钮分别保存。

{% tabs %}
{% tab title="Node.js / Express" %}
示例使用 CommonJS，将两个代码标签中的源码分别保存为 `verify.js` 和 `receiver.js`，放在同一目录。回调路由必须注册在全局 `express.json()` 之前，使用原始请求体验签后再解析 JSON。

{% tabs %}
{% tab title="verify.js" %}
{% code lineNumbers="true" %}

```javascript
'use strict';
const crypto = require('node:crypto');
const fields = ['x-event-timestamp', 'x-event-id', 'x-event-type', 'x-event-version'];

// Supply this user's Base64 secret and a Buffer containing the raw body.
// The HTTP adapter must reject duplicate signing headers before collapsing them into an object.
function verify(secretBase64, headers, rawBody, now = Math.floor(Date.now() / 1000)) {
  try {
    const normalized = new Map();
    for (const [name, value] of Object.entries(headers)) {
      const lower = name.toLowerCase();
      if (normalized.has(lower)) return false;
      normalized.set(lower, value);
    }
    const values = fields.map(name => normalized.get(name));
    if (values.some(value => typeof value !== 'string' || !/^[\x20-\x7e]+$/.test(value) || /[\r\n]/.test(value))) return false;
    const [timestamp] = values;
    if (!/^[0-9]{1,12}$/.test(timestamp) || Math.abs(now - Number(timestamp)) > 300) return false;
    const signature = normalized.get('x-event-signature');
    if (typeof signature !== 'string' || signature.length !== 67 || !/^v1=[0-9a-f]{64}$/.test(signature)) return false;
    const encoded = secretBase64;
    if (typeof encoded !== 'string' || encoded.length !== 44 || !/^[A-Za-z0-9+/]{43}=$/.test(encoded)) return false;
    const secret = Buffer.from(encoded, 'base64');
    if (secret.length !== 32 || !Buffer.isBuffer(rawBody)) return false;
    const prefix = Buffer.from(['v1', ...values].join('\n') + '\n', 'utf8');
    const expected = crypto.createHmac('sha256', secret).update(prefix).update(rawBody).digest();
    return crypto.timingSafeEqual(expected, Buffer.from(signature.slice(3), 'hex'));
  } catch {
    return false;
  }
}
module.exports = { verify };

if (require.main === module) {
  const assert = require('node:assert/strict');
  const fixture = require('./test-vector.json');
  const { headers, now } = fixture;
  const secret = fixture.secret;
  const body = Buffer.from(fixture.body, 'utf8');
  assert(verify(secret, headers, body, now));
  assert(!verify(secret, headers, Buffer.concat([body, Buffer.from('\n')]), now));
  assert(!verify(secret, headers, body, now + 301));
  assert(!verify(secret, headers, body, now - 301));
  for (const name of Object.keys(headers)) {
    assert(!verify(secret, { ...headers, [name]: headers[name] + '1' }, body, now), name);
  }
  assert(!verify(Buffer.alloc(32).toString('base64'), headers, body, now));
  console.log('Node.js: valid signature, tampering, wrong keys, expired/future timestamps verified');
}
```

{% endcode %}
{% endtab %}

{% tab title="receiver.js" %}
{% code lineNumbers="true" %}

```javascript
const express = require('express');
const { verify } = require('./verify');
const app = express();
const secret = process.env.CATFEE_WEBHOOK_SECRET;
if (!secret) throw new Error('请配置 CATFEE_WEBHOOK_SECRET');

const signedHeaders = [
  'x-event-timestamp', 'x-event-id', 'x-event-type',
  'x-event-version', 'x-event-signature',
];

// 此路由必须放在 app.use(express.json()) 之前。
app.post('/callback', express.raw({ type: 'application/json', limit: '1mb' }), (req, res) => {
  // 使用 rawHeaders 检查重复头，避免框架合并后丢失信息。
  const names = req.rawHeaders.filter((_, index) => index % 2 === 0)
    .map(name => name.toLowerCase());
  if (signedHeaders.some(name => names.filter(value => value === name).length !== 1)) {
    return res.status(400).send('invalid headers');
  }
  if (!verify(secret, req.headers, req.body)) {
    return res.status(401).send('invalid signature');
  }

  let event;
  try {
    event = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.status(400).send('invalid json');
  }
  // TODO：按用户、X-EVENT-TYPE、X-EVENT-ID 做持久化幂等，并可靠处理 event。
  // 业务失败时返回非 2xx；完成可靠接收后才返回 success。
  return res.status(200).type('text/plain').send('success');
});

app.listen(8080);
```

{% endcode %}
{% endtab %}
{% endtabs %}
{% endtab %}

{% tab title="Python / FastAPI" %}
将两个代码标签中的源码分别保存为 `verify.py` 和 `receiver.py`，放在同一目录。通过 `await request.body()` 获取原始字节，验签通过后再解析 JSON；不要将 `await request.json()` 的结果重新序列化用于验签。

{% tabs %}
{% tab title="verify.py" %}
{% code lineNumbers="true" %}

```python
"""Verify raw webhook bytes before JSON parsing; then deduplicate the authenticated event."""
import base64
import hashlib
import hmac
import re
import time

FIELDS = ("x-event-timestamp", "x-event-id", "x-event-type", "x-event-version")


def verify(secret_b64, headers, raw_body, now=None):
    """secret_b64 is this user's shared secret; headers must contain single values."""
    try:
        normalized = {}
        for name, value in headers.items():
            name = name.lower()
            if name in normalized:
                return False
            normalized[name] = value
        values = [normalized[name] for name in FIELDS]
        if any(not re.fullmatch(r"[\x20-\x7e]+", value) for value in values):
            return False
        timestamp = values[0]
        if not re.fullmatch(r"[0-9]{1,12}", timestamp):
            return False
        current_time = int(time.time()) if now is None else now
        if abs(current_time - int(timestamp)) > 300:
            return False
        signature = normalized["x-event-signature"]
        if not re.fullmatch(r"v1=[0-9a-f]{64}", signature):
            return False
        secret = base64.b64decode(secret_b64, validate=True)
        if len(secret) != 32:
            return False
        prefix = ("v1\n" + "\n".join(values) + "\n").encode("utf-8")
        expected = hmac.new(secret, prefix + raw_body, hashlib.sha256).digest()
        return hmac.compare_digest(expected, bytes.fromhex(signature[3:]))
    except (KeyError, ValueError, TypeError, AttributeError):
        return False


if __name__ == "__main__":
    import json
    from pathlib import Path
    fixture = json.loads(Path(__file__).with_name("test-vector.json").read_text())
    headers = fixture["headers"]
    secret = fixture["secret"]
    body = fixture["body"].encode("utf-8")
    now = fixture["now"]
    assert verify(secret, headers, body, now)
    assert not verify(secret, headers, body + b"\n", now)
    assert not verify(secret, headers, body, now + 301)
    assert not verify(secret, headers, body, now - 301)
    for name in headers:
        tampered = dict(headers)
        tampered[name] += "1"
        assert not verify(secret, tampered, body, now), name
    assert not verify(base64.b64encode(bytes(32)), headers, body, now)
    print("Python: valid signature, tampering, wrong keys, expired/future timestamps verified")
```

{% endcode %}
{% endtab %}

{% tab title="receiver.py" %}
{% code lineNumbers="true" %}

```python
import json
import os
from fastapi import FastAPI, Request, Response
from verify import verify

app = FastAPI()
secret = os.environ["CATFEE_WEBHOOK_SECRET"]
signed_headers = (
    "x-event-timestamp", "x-event-id", "x-event-type",
    "x-event-version", "x-event-signature",
)

@app.post("/callback")
async def callback(request: Request):
    if any(len(request.headers.getlist(name)) != 1 for name in signed_headers):
        return Response("invalid headers", status_code=400)
    raw_body = await request.body()
    if not verify(secret, dict(request.headers), raw_body):
        return Response("invalid signature", status_code=401)
    try:
        event = json.loads(raw_body)
    except (ValueError, UnicodeDecodeError):
        return Response("invalid json", status_code=400)
    # TODO：按用户、X-EVENT-TYPE、X-EVENT-ID 做持久化幂等，并可靠处理 event。
    # 业务失败时返回非 2xx；完成可靠接收后才返回 success。
    return Response("success", media_type="text/plain")
```

{% endcode %}
{% endtab %}
{% endtabs %}
{% endtab %}

{% tab title="Java / Spring Boot" %}
将两个代码标签中的源码分别保存为 `VerifyWebhook.java` 和 `CallbackController.java`，放入同一个包，并补上对应的 `package` 声明。要求 Java 17 或更高版本。`@RequestBody` 使用 `byte[]` 接收原始字节，验签通过后再解析为业务对象。

{% tabs %}
{% tab title="VerifyWebhook.java" %}
{% code lineNumbers="true" %}

```java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Base64;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.Locale;
import java.util.Map;

/** Verify raw bytes before parsing JSON; deduplicate the authenticated event separately. */
public class VerifyWebhook {
    private static final String[] FIELDS = {
        "x-event-timestamp", "x-event-id", "x-event-type", "x-event-version"
    };

    public static boolean verify(String secretBase64, Map<String, String> headers, byte[] rawBody) {
        return verify(secretBase64, headers, rawBody, Instant.now().getEpochSecond());
    }

    // The HTTP adapter must reject duplicate signing headers before constructing the map.
    public static boolean verify(String secretBase64, Map<String, String> headers, byte[] rawBody, long now) {
        try {
            Map<String, String> normalized = new HashMap<>();
            for (var entry : headers.entrySet()) {
                if (normalized.putIfAbsent(entry.getKey().toLowerCase(Locale.ROOT), entry.getValue()) != null) return false;
            }
            StringBuilder prefix = new StringBuilder("v1\n");
            for (String field : FIELDS) {
                String value = normalized.get(field);
                if (value == null || !value.matches("[\\x20-\\x7e]+")) return false;
                prefix.append(value).append('\n');
            }
            String timestamp = normalized.get("x-event-timestamp");
            if (!timestamp.matches("[0-9]{1,12}")) return false;
            long sentAt = Long.parseLong(timestamp);
            if (sentAt < now - 300 || sentAt > now + 300) return false;
            String signature = normalized.get("x-event-signature");
            if (signature == null || !signature.matches("v1=[0-9a-f]{64}")) return false;
            String encodedSecret = secretBase64;
            if (encodedSecret == null) return false;
            byte[] secret = Base64.getDecoder().decode(encodedSecret);
            if (secret.length != 32) return false;
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secret, "HmacSHA256"));
            mac.update(prefix.toString().getBytes(StandardCharsets.UTF_8));
            return MessageDigest.isEqual(mac.doFinal(rawBody), HexFormat.of().parseHex(signature.substring(3)));
        } catch (Exception ignored) {
            return false;
        }
    }

    public static void main(String[] args) {
        String secretBase64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=";
        Map<String, String> headers = Map.of(
            "X-EVENT-TIMESTAMP", "1790726400", "X-EVENT-ID", "test-order",
            "X-EVENT-TYPE", "EVENT_DELEGATION", "X-EVENT-VERSION", "20260930",
            "X-EVENT-SIGNATURE", "v1=0405c17d7abae89dd9afdfa7638644cc4dc73a23ebc96dbcb1628a63f4cb3d49");
        byte[] body = "{\"event_id\":\"D-test-order\",\"data\":{\"business_type\":\"BUSINESS_TIME_BASED_RENTAL\",\"remark\":\"签名测试\"}}".getBytes(StandardCharsets.UTF_8);
        long now = 1790726400L;
        if (!verify(secretBase64, headers, body, now)) throw new AssertionError("Valid signature rejected");
        if (verify(secretBase64, headers, new byte[0], now)) throw new AssertionError("Modified body accepted");
        if (verify(secretBase64, headers, body, now + 301)) throw new AssertionError("Expired timestamp accepted");
        if (verify(secretBase64, headers, body, now - 301)) throw new AssertionError("Future timestamp accepted");
        for (String name : headers.keySet()) {
            Map<String, String> tampered = new HashMap<>(headers);
            tampered.put(name, headers.get(name) + "1");
            if (verify(secretBase64, tampered, body, now)) throw new AssertionError("Modified header accepted: " + name);
        }
        if (verify(Base64.getEncoder().encodeToString(new byte[32]), headers, body, now)) throw new AssertionError("Wrong key accepted");
        System.out.println("Java: valid signature, tampering, wrong keys, expired/future timestamps verified");
    }
}
```

{% endcode %}
{% endtab %}

{% tab title="CallbackController.java" %}
{% code lineNumbers="true" %}

```java
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.Objects;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
public class CallbackController {
    private final ObjectMapper mapper;
    private final String secret = Objects.requireNonNull(
        System.getenv("CATFEE_WEBHOOK_SECRET"), "请配置 CATFEE_WEBHOOK_SECRET");

    public CallbackController(ObjectMapper mapper) {
        this.mapper = mapper;
    }

    @PostMapping(value = "/callback", consumes = MediaType.APPLICATION_JSON_VALUE,
                 produces = MediaType.TEXT_PLAIN_VALUE)
    public ResponseEntity<String> callback(@RequestHeader HttpHeaders headers,
                                           @RequestBody byte[] rawBody) throws Exception {
        for (String name : List.of("X-EVENT-TIMESTAMP", "X-EVENT-ID", "X-EVENT-TYPE",
                                   "X-EVENT-VERSION", "X-EVENT-SIGNATURE")) {
            var values = headers.get(name);
            if (values == null || values.size() != 1) {
                return ResponseEntity.badRequest().body("invalid headers");
            }
        }
        if (!VerifyWebhook.verify(secret, headers.toSingleValueMap(), rawBody)) {
            return ResponseEntity.status(401).body("invalid signature");
        }
        var event = mapper.readTree(rawBody);
        // TODO：按用户、X-EVENT-TYPE、X-EVENT-ID 做持久化幂等，并可靠处理 event。
        // 业务失败时返回非 2xx；完成可靠接收后才返回 success。
        return ResponseEntity.ok("success");
    }
}
```

{% endcode %}
{% endtab %}
{% endtabs %}
{% endtab %}
{% endtabs %}

以上控制器用于快速联调验签，`TODO` 处必须接入实际业务或可靠队列后再上线。若回调入口服务于多个 CatFee 用户，应通过自己配置的路由或其他可信上下文选择用户密钥，不能信任未验签 body 声称的用户身份。

## 防重放、幂等与重试

签名有效并不表示这条通知从未收到过。接收端还需要：

* **时间窗口**：建议仅接受与当前时间相差不超过 300 秒的请求，同时拒绝过旧和过远的未来时间。示例已经实现此检查，请保持服务器时钟同步。
* **持久化幂等**：按“用户＋`X-EVENT-TYPE`＋`X-EVENT-ID`”做原子去重。只在业务事务成功或消息已可靠入队后标记完成，处理失败时允许重试；不要用仅保存在进程内的 `Set` 代替生产幂等。
* **重复通知**：对于已成功处理的事件，返回 `HTTP 200` 和 `success`，不要再次执行业务。
* **重试验签**：平台每次发送重新生成时间戳和签名，事件 ID 不变。验签时间使用 `X-EVENT-TIMESTAMP`，不要使用 body 内的业务发生时间；同一秒内的相同请求可能有相同签名。

签名失败返回非 2xx；验签通过且可靠接收后返回 `HTTP 200`，正文为 `success`。签名回调不自动跟随 3xx 重定向，请直接配置最终接收地址。

## 使用固定测试向量验证

将以下内容保存为 `test-vector.json`，与验签源码放在同一目录。**仅用于本地测试，不可用于正式回调**：

{% code title="test-vector.json" lineNumbers="true" %}

```json
{
  "secret": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
  "now": 1790726400,
  "headers": {
    "X-EVENT-TIMESTAMP": "1790726400",
    "X-EVENT-ID": "test-order",
    "X-EVENT-TYPE": "EVENT_DELEGATION",
    "X-EVENT-VERSION": "20260930",
    "X-EVENT-SIGNATURE": "v1=0405c17d7abae89dd9afdfa7638644cc4dc73a23ebc96dbcb1628a63f4cb3d49"
  },
  "body": "{\"event_id\":\"D-test-order\",\"data\":{\"business_type\":\"BUSINESS_TIME_BASED_RENTAL\",\"remark\":\"签名测试\"}}"
}
```

{% endcode %}

原始 body 是以下单行 UTF-8 文本，行末没有换行：

```json
{"event_id":"D-test-order","data":{"business_type":"BUSINESS_TIME_BASED_RENTAL","remark":"签名测试"}}
```

按所选语言运行自测：

```bash
python3 verify.py
node verify.js
java VerifyWebhook.java
```

示例自测将当前时间固定为 `1790726400`，校验正确签名、正文或请求头篡改、错误密钥以及时间窗口。实际服务器验签必须使用真实当前时间，不能沿用测试中的固定时间。

完成自测后，在用户中心对已配置的订阅发送测试通知，验证真实回调能通过验签。测试通知使用同一签名开关和密钥，目前支持余额、订阅和代理事件；测试通知不会写入重试队列。

## 常见问题

| 问题              | 排查方式                                                        |
| --------------- | ----------------------------------------------------------- |
| 验签一直失败          | 确认使用的是回调 `secret`，并先做标准 Base64 解码；不要使用购买能量 API 的 API Secret |
| 字段一样但签名不同       | 检查是否重新序列化 JSON，或增加了空格、Unicode 转义、末尾换行；应对原始字节验签              |
| 事件 ID 对不上       | 签名取 `X-EVENT-ID` 请求头，不取可能带 `D-`、`B-` 前缀的 JSON `event_id`    |
| 时间戳校验失败         | 使用 Unix **秒**，不是毫秒或 ISO 日期；检查服务器时钟，不要用业务发生时间验签              |
| 开启后收到旧事件        | 开关作用于实际发送，已有待重试通知也会加签；按本次发送时间验签即可                           |
| 关闭后再开启是否换 key   | 不会，用户继续使用同一个密钥                                              |
| HTTP 是否支持       | 支持，验签规则与 HTTPS 相同；需要传输加密时使用 HTTPS                           |
| 为什么回调地址跳转后收不到通知 | 签名回调不跟随重定向，应直接填写最终接收 URL                                    |

返回[回调（Webhook）](/getting-started/webhook.md)查看事件结构和业务字段。
