# 身份验证与请求签名

> 使用 project UUID 和 API key 通过 HMAC-SHA256 对 API 请求签名。

每个 API 请求（接收方向的 webhook 除外）都必须带上 project UUID 和请求签名。签名用于证明请求来自您本人，并且在传输过程中没有被篡改。

## API 密钥

2328.io 使用 **两套密钥**，签名算法完全相同，但作用范围不同：

| 密钥 | 使用范围 |
|------|---------|
| **API key** | 支付、静态钱包、余额、汇率端点，以及支付 / 静态钱包 webhook 的签名校验 |
| **Payout API key** | 所有 `/v1/payout/*` 端点，以及提现 webhook 的签名校验 |

两套密钥都在 [2328.io](https://2328.io) 的项目设置中创建。下文示例中的 "API key" 是通用占位 — 请根据所调用端点替换为正确的密钥。

> **INFO:** **切勿混用** 两套密钥：用普通 API key 签名提现请求（或反之）会返回签名错误。

## 必需的请求头

| 请求头 | 类型 | 必需 | 描述 |
|--------|------|------|------|
| `Content-Type` | string | 是 | 始终为 `application/json` |
| `project` | string | 是 | 您的项目 UUID |
| `sign` | string | 是 | 使用 API key 计算的请求 HMAC-SHA256 签名 |
| `User-Agent` | string | 是 | 标识您的应用（例如 `MyShop/1.4 (+https://myshop.example)`）。不带 `User-Agent` 的请求可能会被拦截。 |

## 签名是如何生成的

可以把签名理解为请求体的「指纹」。生成步骤：

1. 将请求体序列化为 JSON（紧凑格式，无多余空白）。
2. 对该 JSON 进行 Base64 编码。这一步把输入归一化为纯 ASCII — 这样不同语言计算出的 HMAC 才能保持一致。
3. 使用您的 API key 对 Base64 字符串计算 **HMAC-SHA256**，结果以小写十六进制表示。

对于 **GET** 等没有请求体的请求，对空字符串进行签名即可。

> **INFO:** 对于给定的 API key，空字符串签名始终相同。如果有大量 GET 调用，可以将其缓存。

## 各语言实现

<CodeSnippet name="apiSign" langs="php,js,ts,python,go" />

### 无请求体的请求（GET）

对于没有请求体的请求（例如 `GET /v1/payout/status/{uuid}`），对空字符串签名：

<CodeSnippet name="apiSignBodyless" langs="curl,php,js,ts,python,go" />

## 完整请求示例

<CodeSnippet name="fullRequestExample" langs="curl,php,js,ts,python,go" />

> **DANGER:** **切勿在客户端代码中暴露您的 API key。** 所有签名必须在您的后端完成。一旦 API key 泄露，任何人都可以完全访问您的商户账户。

## 校验 webhook 签名

当 2328.io 发送 webhook 给您时，使用相同的算法反向校验：

1. 从载荷中取出 `sign` 字段。
2. 将剩余字段编码为紧凑 JSON（无空白）。
3. 对结果做 Base64 编码。
4. 使用对应的密钥计算 `HMAC-SHA256`。
5. 使用 **恒定时间** 比较函数（`hash_equals`、`crypto.timingSafeEqual`、`hmac.compare_digest`、`subtle.ConstantTimeCompare`、`OpenSSL.fixed_length_secure_compare`）与收到的 `sign` 比较。

签名密钥根据 webhook 来源不同而不同：

| Webhook | 校验密钥 |
|---------|---------|
| 支付 / 静态钱包 webhook（`/v1/payment`、`/v1/static-wallet`） | **API key** |
| 提现 webhook（`/v1/payout`） | **Payout API key** |

> **WARNING:** **常见的校验陷阱。** 您的 JSON 编码器必须产生与发送方**完全相同的字节** — 否则 Base64 会不同，签名也无法匹配。

- **Go**：使用 `json.NewEncoder` 并设置 `SetEscapeHTML(false)`。默认的 `json.Marshal` 会将 `<`、`>`、`&` 转义为 `<`，从而破坏签名。
- **Python**：在 `json.dumps` 中传入 `ensure_ascii=False`。否则非 ASCII 字符（西里尔字母、中文等）会被转义为 `\uXXXX`。
- **紧凑 JSON**：字段之间不能有空白（在 Python 中使用 `separators=(",", ":")`）。
- **字段顺序**（Go）：普通的 `map[string]any` 在重新编码时会随机化键的顺序。请使用 `json.RawMessage`、有序的 struct，或从原始字节中剥离 `sign`。

如果校验始终失败，请自己对载荷运行 `apiSign` — 它必须产生与收到的 `sign` 相同的十六进制字符串。

> **INFO:** **有效的签名并不能防止重放。** 它只能证明 webhook 来自 2328.io — 并不能阻止攻击者稍后重新发送*已捕获*的 webhook。在记账之前，请始终通过 `uuid`（或对静态钱包使用 `txid`）检查幂等性。如果签名缺失或错误，请以 HTTP `401` 拒绝。

完整的代码示例请见 **[Webhook 通知](/docs/webhooks#verifying-the-signature)**。重试处理与幂等性规则见 [最佳实践](/docs/webhooks#best-practices)。