# 请求签名

> 了解 Onerway 如何通过请求签名对 API 请求进行身份鉴权、如何生成 sign 字段，以及如何验签 webhook 通知。

Onerway 依赖 `sign` 字段对 API 请求进行身份鉴权。商户服务端需使用[对应环境的密钥 (`SECRET`)](/zh/payments/get-started/setup#%E8%8E%B7%E5%8F%96%E5%87%AD%E8%AF%81)，根据请求报文动态计算签名 (`sign`)；**严禁**在前端、移动端或公开的代码仓库中暴露 `SECRET`。

从服务端调用 API 前，必须完成对应环境的 [IP 白名单配置](/zh/payments/get-started/setup#%E9%85%8D%E7%BD%AE-ip-%E7%99%BD%E5%90%8D%E5%8D%95)。沙盒和生产环境需分别配置。

## 签名规则

生成 `sign` 时，请按以下顺序处理请求参数：

1. 排除 `sign` 字段。
2. 排除值为 `null`、`undefined` 和空字符串的字段。
3. 若字段值为对象 (Object) 或数组 (Array)，将其序列化为紧凑的 JSON 字符串（无多余空格或换行）；嵌套结构的序列化规则见[对象和数组字段转换](#%E5%AF%B9%E8%B1%A1%E5%92%8C%E6%95%B0%E7%BB%84%E5%AD%97%E6%AE%B5%E8%BD%AC%E6%8D%A2)。
4. 按字段名的 ASCII 码升序排列。
5. 仅将排序后的字段值直接拼接，不要拼接字段名、等号 (`=`)、与号 (`&`) 或任何其他分隔符；拼接后的字符串称为 **Canonical String**。
6. 在 Canonical String 末尾追加 `SECRET`。
7. 对最终的字符串进行 `SHA-256` 哈希计算，并将结果转换为小写的十六进制字符串。

非字符串标量参与拼接时：布尔值转为 `true` / `false`；数字转为十进制字符串，不含多余尾零或科学计数法（例如 `1.10` 拼接为 `1.1`）。

## 对象和数组字段转换

对象或数组字段需要序列化为紧凑的 JSON 字符串：

```json
{
  "billingInformation": {
    "country": "US",
    "email": "customer@test.com"
  }
}
```

转换后参与签名并发送：

```json
{
  "billingInformation": "{\"country\":\"US\",\"email\":\"customer@test.com\"}"
}
```

嵌套字段需要先转换内层，再转换外层。例如 `txnOrderMsg.products` 是数组，最终会作为 `txnOrderMsg` 字符串里的 JSON 字符串：

```json
{
  "txnOrderMsg": "{\"appId\":\"replace_with_app_id\",\"products\":\"[{\\\"currency\\\":\\\"USD\\\",\\\"name\\\":\\\"test product\\\",\\\"num\\\":\\\"1\\\",\\\"price\\\":\\\"1\\\"}]\",\"returnUrl\":\"https://developers.onerway.com/example-return\"}"
}
```

## 请求示例

原始请求可以保留对象结构，`sign` 字段为空：

```json
{
  "merchantNo": "replace_with_merchant_no",
  "merchantTxnId": "replace_with_unique_transaction_id",
  "merchantTxnTime": "2026-04-24 15:37:39",
  "orderAmount": "1",
  "orderCurrency": "USD",
  "billingInformation": {
    "country": "US",
    "email": "customer@test.com",
    "province": "CA"
  },
  "txnOrderMsg": {
    "appId": "replace_with_app_id",
    "products": [
      {
        "currency": "USD",
        "name": "test product",
        "num": "1",
        "price": "1"
      }
    ],
    "returnUrl": "https://developers.onerway.com/example-return"
  },
  "productType": "CARD",
  "subProductType": "DIRECT",
  "txnType": "SALE",
  "sign": null
}
```

发送前应把对象字段转换为字符串，并填入计算后的 `sign`：

```json
{
  "billingInformation": "{\"country\":\"US\",\"email\":\"customer@test.com\",\"province\":\"CA\"}",
  "merchantNo": "replace_with_merchant_no",
  "merchantTxnId": "replace_with_unique_transaction_id",
  "merchantTxnTime": "2026-04-24 15:37:39",
  "orderAmount": "1",
  "orderCurrency": "USD",
  "productType": "CARD",
  "sign": "replace_with_calculated_signature",
  "subProductType": "DIRECT",
  "txnOrderMsg": "{\"appId\":\"replace_with_app_id\",\"products\":\"[{\\\"currency\\\":\\\"USD\\\",\\\"name\\\":\\\"test product\\\",\\\"num\\\":\\\"1\\\",\\\"price\\\":\\\"1\\\"}]\",\"returnUrl\":\"https://developers.onerway.com/example-return\"}",
  "txnType": "SALE"
}
```

最终请求体可以按字段名排序，便于排查；签名是否正确仅取决于签名规则中的排序和拼接结果。

## 签名算例

用以下最小请求和示例密钥 `example_secret` 对拍你的实现：

```json
{
  "merchantNo": "demo_merchant_no",
  "merchantTxnId": "demo_txn_id_001",
  "orderAmount": "1",
  "orderCurrency": "USD",
  "sign": null
}
```

```text
Canonical String: demo_merchant_nodemo_txn_id_0011USD
待哈希字符串:     demo_merchant_nodemo_txn_id_0011USDexample_secret
sign:            8af506acd9322fcab9d676dd9b861512f194d9ab724eacdb358d90d087d9caac
```

## 代码示例

以下示例默认输入是商户服务端内存中的字典 (Map) 或对象 (Object)；若某个字段已经是 JSON 字符串，请确保它已按同样的由内向外规则完成内层转换。`normalizeValue` 的结果应同时用于签名和最终请求体；不要在计算签名后用不同的序列化方式重新构建对象或数组字段，这会导致网关验签失败。

<code-collapse name="代码示例">
<code-group sync="signing-lang">

```php [PHP]
<?php

function is_assoc_array(array $value): bool {
    if ($value === []) {
        return false;
    }
    return array_keys($value) !== range(0, count($value) - 1);
}

function normalize_nested($value) {
    if (!is_array($value)) {
        return $value;
    }

    if (!is_assoc_array($value)) {
        return array_map('normalize_nested', $value);
    }

    $result = [];
    foreach ($value as $key => $child) {
        $result[$key] = is_array($child)
            ? json_encode(normalize_nested($child), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
            : $child;
    }
    return $result;
}

function normalize_value($value) {
    if ($value === null || $value === '') {
        return $value;
    }
    if (is_bool($value)) {
        return $value ? 'true' : 'false';
    }
    return is_array($value)
        ? json_encode(normalize_nested($value), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
        : (string) $value;
}

function generate_signature(array $payload, string $secret): string {
    $normalized = [];
    foreach ($payload as $key => $value) {
        if ($key !== 'sign') {
            $normalized[$key] = normalize_value($value);
        }
    }

    ksort($normalized, SORT_STRING);

    $canonical = '';
    foreach ($normalized as $value) {
        if ($value !== null && $value !== '') {
            $canonical .= $value;
        }
    }

    return hash('sha256', $canonical . $secret);
}
```

```java [Java]
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

public final class OnerwaySign {
    private static final ObjectMapper JSON = new ObjectMapper();

    private static Object normalizeNested(Object value) throws JsonProcessingException {
        if (value instanceof List<?> list) {
            List<Object> result = new ArrayList<>();
            for (Object item : list) {
                result.add(normalizeNested(item));
            }
            return result;
        }

        if (value instanceof Map<?, ?> map) {
            TreeMap<String, Object> result = new TreeMap<>();
            for (Map.Entry<?, ?> entry : map.entrySet()) {
                Object child = entry.getValue();
                result.put(
                    String.valueOf(entry.getKey()),
                    child instanceof Map<?, ?> || child instanceof List<?>
                        ? JSON.writeValueAsString(normalizeNested(child))
                        : child
                );
            }
            return result;
        }

        return value;
    }

    private static String normalizeValue(Object value) throws JsonProcessingException {
        if (value == null) {
            return null;
        }
        if (value instanceof String text) {
            return text.isEmpty() ? null : text;
        }
        if (value instanceof Map<?, ?> || value instanceof List<?>) {
            return JSON.writeValueAsString(normalizeNested(value));
        }
        return String.valueOf(value);
    }

    public static String generateSignature(Map<String, Object> payload, String secret) throws Exception {
        TreeMap<String, String> normalized = new TreeMap<>();
        for (Map.Entry<String, Object> entry : payload.entrySet()) {
            if (!"sign".equals(entry.getKey())) {
                normalized.put(entry.getKey(), normalizeValue(entry.getValue()));
            }
        }

        StringBuilder canonical = new StringBuilder();
        for (String value : normalized.values()) {
            if (value != null && !value.isEmpty()) {
                canonical.append(value);
            }
        }

        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] hash = digest.digest((canonical + secret).getBytes(StandardCharsets.UTF_8));

        StringBuilder hex = new StringBuilder();
        for (byte b : hash) {
            hex.append(String.format("%02x", b));
        }
        return hex.toString();
    }
}
```

```go [Go]
package signing

import (
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "sort"
)

func normalizeNested(value any) any {
    switch typed := value.(type) {
    case []any:
        result := make([]any, len(typed))
        for i, item := range typed {
            result[i] = normalizeNested(item)
        }
        return result
    case map[string]any:
        result := map[string]any{}
        for key, child := range typed {
            switch child.(type) {
            case map[string]any, []any:
                bytes, _ := json.Marshal(normalizeNested(child))
                result[key] = string(bytes)
            default:
                result[key] = child
            }
        }
        return result
    default:
        return value
    }
}

func normalizeValue(value any) *string {
    if value == nil {
        return nil
    }

    switch typed := value.(type) {
    case string:
        if typed == "" {
            return nil
        }
        return &typed
    case map[string]any, []any:
        bytes, _ := json.Marshal(normalizeNested(value))
        text := string(bytes)
        return &text
    default:
        text := fmt.Sprint(typed)
        return &text
    }
}

func GenerateSignature(payload map[string]any, secret string) string {
    keys := make([]string, 0, len(payload))
    normalized := map[string]*string{}

    for key, value := range payload {
        if key == "sign" {
            continue
        }
        normalized[key] = normalizeValue(value)
        keys = append(keys, key)
    }

    sort.Strings(keys)

    canonical := ""
    for _, key := range keys {
        if value := normalized[key]; value != nil {
            canonical += *value
        }
    }

    sum := sha256.Sum256([]byte(canonical + secret))
    return hex.EncodeToString(sum[:])
}
```

```js [JavaScript]
import { createHash } from 'node:crypto'

function isPlainObject(value) {
  return Object.prototype.toString.call(value) === '[object Object]'
}

function normalizeNested(value) {
  if (Array.isArray(value)) {
    return value.map((item) => normalizeNested(item))
  }

  if (isPlainObject(value)) {
    const result = {}
    Object.keys(value).forEach((key) => {
      const child = value[key]
      result[key] = child !== null && typeof child === 'object'
        ? JSON.stringify(normalizeNested(child))
        : child
    })
    return result
  }

  return value
}

function normalizeValue(value) {
  if (value === null || value === undefined || value === '') {
    return value
  }
  if (typeof value === 'object') {
    return JSON.stringify(normalizeNested(value))
  }
  return String(value)
}

export function generateSignature(payload, secret) {
  const canonical = Object.keys(payload)
    .filter((key) => key !== 'sign')
    .sort()
    .map((key) => normalizeValue(payload[key]))
    .filter((value) => value !== null && value !== undefined && value !== '')
    .join('')

  return createHash('sha256')
    .update(canonical + secret, 'utf8')
    .digest('hex')
}
```

</code-group>
</code-collapse>

## Webhook 验签

分账、分账回退，以及 Ethoca / RDR 报备状态通知使用通知体中的 `sign` 验签；其他通知使用下述 `X-Rh-Signature` header。各通知的签名参与字段见对应 API Reference。

### 分账及分账回退通知

使用当前环境的 `SECRET`，按[签名规则](#%E7%AD%BE%E5%90%8D%E8%A7%84%E5%88%99)计算收到的通知的签名，并与通知体中的 `sign` 比较。排除 `sign` 自身；`relatedTxnId`、`relatedMerchantTxnId` 参与签名，值为 `null` 时按签名规则排除。`receivers` 使用收到的原始 JSON 字符串，不要解析后重新序列化。完整字段见[分账结果通知](/zh/payments/api-reference/webhooks/profit-share-result)。

`/profit/query` 查询响应也返回 `data.sign`，但商户无需对查询响应验签。

### Ethoca / RDR 报备状态通知

使用当前环境的 `SECRET`，按[签名规则](#%E7%AD%BE%E5%90%8D%E8%A7%84%E5%88%99)计算收到的通知的签名，并与通知体中的 `sign` 比较。排除 `sign` 自身，其余字段按通用规则参与签名；值为 `null` 或空字符串时按该规则排除。

`id` 在通知中为 JSON 数字，可能超出 JavaScript 安全整数范围。接收报文时应使用能无损保留大整数的解析方式，保留原始十进制值用于验签与应答；不要先转换为可能丢失精度的 JavaScript `number`。

完整字段和应答要求见 [Ethoca 报备状态通知](/zh/payments/api-reference/webhooks/ethoca-enrollment-changed)与 [RDR 报备状态通知](/zh/payments/api-reference/webhooks/rdr-enrollment-changed)。Ethoca 预警通知仍使用下述 header 验签。

### 其他通知

Onerway 在通知 header `X-Rh-Signature` 中携带签名：逗号分隔的一项或多项 `v1=<签名>`，`v1` 标识签名方案版本。存在多个同时启用的密钥时（例如密钥轮换期间），每个启用的密钥对应一项：

```text
X-Rh-Signature: v1=4bde57c3350c402ca8c3728697cd5d7dbd78c8f0313761607e7f538be3349c39, v1=6b543c2e887e20a98dd4629242dd60050a4cfdda3372cdc17b4422c8fe98d5fa
```

验签时，使用当前配置的 `SECRET` 按[签名规则](#%E7%AD%BE%E5%90%8D%E8%A7%84%E5%88%99)计算收到通知的签名，结果与 `X-Rh-Signature` 中任意一项一致即验签通过。字段选取与拼接沿用同一规则，另有两点差异：

- 额外排除该 webhook 在 API Reference 页面[「验签范围」](/zh/payments/api-reference/webhooks/payment-result)中标注为不参与验签的字段，**即使通知返回了这些字段的值**；未来新增的通知字段默认参与验签。
- 对象和数组字段在通知中已按紧凑 JSON 字符串承载，直接使用收到的原始字符串值参与拼接，不要重新解析再序列化。

通知 body 仍返回 `sign` 字段，但它仅使用第一个启用的密钥计算，密钥轮换期间，它可能与你使用当前配置的 `SECRET` 计算出的签名不同。这些通知应使用 `X-Rh-Signature` 验签，不要依赖 body `sign`；使用 body `sign` 的通知按前述对应小节处理。

## 常见错误

- 对象 (Object) 或数组 (Array) 字段没有按最终发送形态序列化为紧凑的 JSON 字符串。
- `txnOrderMsg.products` 仍是数组对象，而不是 `txnOrderMsg` 字符串里的 JSON 字符串。
- 发送了当前接口未定义的字段：网关按接口定义的字段集合计算签名，多发的字段只进入商户侧计算，导致签名不一致；字段结构与文档不符同理。
- 请求环境不匹配，例如沙盒环境的 `merchantNo` / `SECRET` 请求了生产环境 API，或生产环境凭证请求了沙盒环境 API。

## 需要协助排查时

请求验签失败时，请在商户服务端打印并提供以下信息：

- 签名计算前的最终拼接字符串 (Canonical String)。
- 生成后的 `sign`。
- 请求环境：沙盒环境或生产环境。
- 请求接口和完整请求体。敏感信息可以脱敏，但不要修改参与签名字段的值。

Webhook 验签失败时，请提供收到的完整通知报文、对应 API Reference 指定的 body `sign` 或 `X-Rh-Signature`，以及你计算的 Canonical String 与签名结果。
