Onerway 依赖 sign 字段对 API 请求进行身份鉴权。商户服务端需使用对应环境的密钥 (SECRET),根据请求报文动态计算签名 (sign);严禁在前端、移动端或公开的代码仓库中暴露 SECRET。
从服务端调用 API 前,必须完成对应环境的 IP 白名单配置。沙盒和生产环境需分别配置。
签名规则
生成 sign 时,请按以下顺序处理请求参数:
- 排除
sign字段。 - 排除值为
null、undefined和空字符串的字段。 - 若字段值为对象 (Object) 或数组 (Array),将其序列化为紧凑的 JSON 字符串(无多余空格或换行);嵌套结构的序列化规则见对象和数组字段转换。
- 按字段名的 ASCII 码升序排列。
- 仅将排序后的字段值直接拼接,不要拼接字段名、等号 (
=)、与号 (&) 或任何其他分隔符;拼接后的字符串称为 Canonical String。 - 在 Canonical String 末尾追加
SECRET。 - 对最终的字符串进行
SHA-256哈希计算,并将结果转换为小写的十六进制字符串。
非字符串标量参与拼接时:布尔值转为 true / false;数字转为十进制字符串,不含多余尾零或科学计数法(例如 1.10 拼接为 1.1)。
对象和数组字段转换
对象或数组字段需要序列化为紧凑的 JSON 字符串:
{
"billingInformation": {
"country": "US",
"email": "customer@test.com"
}
}
转换后参与签名并发送:
{
"billingInformation": "{\"country\":\"US\",\"email\":\"customer@test.com\"}"
}
嵌套字段需要先转换内层,再转换外层。例如 txnOrderMsg.products 是数组,最终会作为 txnOrderMsg 字符串里的 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 字段为空:
{
"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:
{
"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 对拍你的实现:
{
"merchantNo": "demo_merchant_no",
"merchantTxnId": "demo_txn_id_001",
"orderAmount": "1",
"orderCurrency": "USD",
"sign": null
}
Canonical String: demo_merchant_nodemo_txn_id_0011USD
待哈希字符串: demo_merchant_nodemo_txn_id_0011USDexample_secret
sign: 8af506acd9322fcab9d676dd9b861512f194d9ab724eacdb358d90d087d9caac
代码示例
以下示例默认输入是商户服务端内存中的字典 (Map) 或对象 (Object);若某个字段已经是 JSON 字符串,请确保它已按同样的由内向外规则完成内层转换。normalizeValue 的结果应同时用于签名和最终请求体;不要在计算签名后用不同的序列化方式重新构建对象或数组字段,这会导致网关验签失败。
<?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);
}
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();
}
}
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[:])
}
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')
}
Webhook 验签
分账、分账回退,以及 Ethoca / RDR 报备状态通知使用通知体中的 sign 验签;其他通知使用下述 X-Rh-Signature header。各通知的签名参与字段见对应 API Reference。
分账及分账回退通知
使用当前环境的 SECRET,按签名规则计算收到的通知的签名,并与通知体中的 sign 比较。排除 sign 自身;relatedTxnId、relatedMerchantTxnId 参与签名,值为 null 时按签名规则排除。receivers 使用收到的原始 JSON 字符串,不要解析后重新序列化。完整字段见分账结果通知。
/profit/query 查询响应也返回 data.sign,但商户无需对查询响应验签。
Ethoca / RDR 报备状态通知
使用当前环境的 SECRET,按签名规则计算收到的通知的签名,并与通知体中的 sign 比较。排除 sign 自身,其余字段按通用规则参与签名;值为 null 或空字符串时按该规则排除。
id 在通知中为 JSON 数字,可能超出 JavaScript 安全整数范围。接收报文时应使用能无损保留大整数的解析方式,保留原始十进制值用于验签与应答;不要先转换为可能丢失精度的 JavaScript number。
完整字段和应答要求见 Ethoca 报备状态通知与 RDR 报备状态通知。Ethoca 预警通知仍使用下述 header 验签。
其他通知
Onerway 在通知 header X-Rh-Signature 中携带签名:逗号分隔的一项或多项 v1=<签名>,v1 标识签名方案版本。存在多个同时启用的密钥时(例如密钥轮换期间),每个启用的密钥对应一项:
X-Rh-Signature: v1=4bde57c3350c402ca8c3728697cd5d7dbd78c8f0313761607e7f538be3349c39, v1=6b543c2e887e20a98dd4629242dd60050a4cfdda3372cdc17b4422c8fe98d5fa
验签时,使用当前配置的 SECRET 按签名规则计算收到通知的签名,结果与 X-Rh-Signature 中任意一项一致即验签通过。字段选取与拼接沿用同一规则,另有两点差异:
- 额外排除该 webhook 在 API Reference 页面「验签范围」中标注为不参与验签的字段,即使通知返回了这些字段的值;未来新增的通知字段默认参与验签。
- 对象和数组字段在通知中已按紧凑 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 与签名结果。