平台对接文档
联系我们
联系我们
  • api 对接流程(必看)
  • api 认证
  • api 接口

商户 API 签名认证说明

1. 概述

商户调用平台 API 时,需要通过以下 Header 完成身份认证和请求签名:

Header说明
X-Access-Key商户 API Key,用于定位商户
X-TimestampUnix 时间戳(秒); 平台会与当前时间校验,不能超过 5 分钟
X-Nonce本次请求唯一随机字符串,用于防重放
X-SignatureHMAC-SHA256 签名结果

认证流程:

  1. 检查 4 个 Header 是否完整。
  2. 解析 Header。
  3. 校验 X-Timestamp 是否在允许的时间窗口内。
  4. 根据 X-Access-Key 查询商户。
  5. 检查商户状态。
  6. 使用 5 分钟 TTL 校验 X-Nonce,防止同一个请求被重复使用。
  7. 读取 HTTP Body , 对 HTTP 请求 Body 的原始字节进行 SHA-256 哈希计算,得到 64 位小写十六进制字符串,并将该字符串加入签名原文。。
  8. 按固定规则生成签名字符串。
  9. 使用商户 ApiSecret 进行 HMAC-SHA256 验签。


2. 请求 Header

示例:

POST /api/v1/game/user-create HTTP/1.1
Content-Type: application/json
X-Access-Key: your-access-key
X-Timestamp: 1757210000
X-Nonce: 9f4a7c2e8b1d
X-Signature: your-hmac-signature

{"username":"test001","nickname":"测试用户"}

3. 签名原文

签名原文由以下内容按固定顺序拼接:

HTTP_METHOD + "\n" +
REQUEST_PATH + "\n" +
TIMESTAMP + "\n" +
NONCE + "\n" +
SHA256(BODY)

例如:

BODY:
{"username":"test001","nickname":"测试用户"}
SHA256(BODY):
ffaddaxqw4452sasdasaf22cbdss0a


POST
/api/v1/game/user-create
1757210000
9f4a7c2e8b1d
ffaddaxqw4452sasdasaf22cbdss0a

注意:

  • HTTP_METHOD 必须使用实际请求方法的 大写,例如 GET、POST。
  • REQUEST_PATH 只使用 URL Path,例如 /api/v1/game/user-create。
  • 不包含协议、域名和 QueryString。
  • TIMESTAMP 必须与 X-Timestamp 完全一致。
  • NONCE 必须与 X-Nonce 完全一致。
  • BODY 必须使用实际发送出去的原始 JSON 字节内容,并将其进行 SHA256(BODY)。
  • JSON 字段顺序、空格、换行等发生变化都会导致签名不同。

4. HMAC-SHA256

使用商户的 ApiSecret 对签名原文进行 HMAC-SHA256:

signature = HMAC-SHA256(ApiSecret, SignString)

最终签名编码为十六进制小写字符串。

例如:

ApiSecret:
abc123456
BODY:
{"username":"test001","nickname":"测试用户"}
SHA256(BODY)后:
ffaddaxqw4452sasdasaf22cbdss0a

SignString:
POST
/api/v1/game/user-create
1757210000
9f4a7c2e8b1d
ffaddaxqw4452sasdasaf22cbdss0a

Signature:
e4d909c290d0fb1ca068ffaddf22cbd0...

5. Timestamp 防重放

客户端必须发送当前 Unix 时间戳。

平台会检查是否超过允许的时间范围。

因此客户端和服务端需要保持较准确的系统时间。

如果时间戳过期,平台返回:

{
  "code": 401,
  "message": "timestamp expired"
}

6. Nonce 防重放

每次请求都应该生成一个全新的随机 Nonce。

如果 Key 已经存在,则认为请求被重复发送:

{
  "code": 401,
  "message": "repeat request"
}

因此:

同一个 AccessKey + Nonce 只能成功使用一次。


7. 示例

Go 示例

package main

import (
	"bytes"
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"net/http"
	"strings"
	"time"
)

func SHA256(data []byte) string {
	sum := sha256.Sum256(data)
	return hex.EncodeToString(sum[:])
}

func HMACSHA256(data []byte, secret string) string {
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write(data)

	return hex.EncodeToString(mac.Sum(nil))
}

func RandomNonce() (string, error) {
	b := make([]byte, 16)

	if _, err := io.ReadFull(rand.Reader, b); err != nil {
		return "", err
	}

	return hex.EncodeToString(b), nil
}

func main() {
	accessKey := "your-access-key"
	secret := "your-api-secret"

	url := "https://api.example.com/api/v1/game/user-create"
	path := "/api/v1/game/user-create"

	body := []byte(`{"username":"test001","nickname":"测试用户"}`)

	method := "POST"

	timestamp := time.Now().Unix()

	nonce, err := RandomNonce()
	if err != nil {
		panic(err)
	}

	// SHA256(body)
	bodyHash := SHA256(body)

	// 构造签名原文
	signString := fmt.Sprintf(
		"%s\n%s\n%d\n%s\n%s",
		strings.ToUpper(method),
		path,
		timestamp,
		nonce,
		bodyHash,
	)

	// HMAC-SHA256
	signature := HMACSHA256(
		[]byte(signString),
		secret,
	)

	req, err := http.NewRequest(
		method,
		url,
		bytes.NewReader(body),
	)
	if err != nil {
		panic(err)
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-Access-Key", accessKey)
	req.Header.Set("X-Timestamp", fmt.Sprintf("%d", timestamp))
	req.Header.Set("X-Nonce", nonce)
	req.Header.Set("X-Signature", signature)

	client := &http.Client{}

	resp, err := client.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()

	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		panic(err)
	}

	fmt.Println(resp.StatusCode)
	fmt.Println(string(respBody))
}

PHP 示例

<?php

$accessKey = 'your-access-key';
$secret = 'your-api-secret';

$url = 'https://api.example.com/api/v1/game/user-create';
$path = '/api/v1/game/user-create';

$body = json_encode([
    'username' => 'test001',
    'nickname' => '测试用户',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

$timestamp = (string) time();
$nonce = bin2hex(random_bytes(16));

$method = 'POST';

// 对 body 做 SHA-256
$bodyHash = hash('sha256', $body);

$signString =
    strtoupper($method) . "\n" .
    $path . "\n" .
    $timestamp . "\n" .
    $nonce . "\n" .
    $bodyHash;

$signature = hash_hmac('sha256', $signString, $secret);

$ch = curl_init($url);

curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/json',
        'X-Access-Key: ' . $accessKey,
        'X-Timestamp: ' . $timestamp,
        'X-Nonce: ' . $nonce,
        'X-Signature: ' . $signature,
    ],
]);

$response = curl_exec($ch);

if ($response === false) {
    throw new RuntimeException(curl_error($ch));
}

curl_close($ch);

echo $response;

Java 示例

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Instant;

public class ApiClient {

    public static void main(String[] args) throws Exception {
        String accessKey = "your-access-key";
        String secret = "your-api-secret";

        String url = "https://api.example.com/api/v1/game/user-create";
        String path = "/api/v1/game/user-create";

        String body = """
                {"username":"test001","nickname":"测试用户"}
                """.trim();

        String method = "POST";

        String timestamp = String.valueOf(Instant.now().getEpochSecond());

        String nonce = randomNonce();

        // SHA256(body)
        String bodyHash = sha256(body);

        // 构造签名原文
        String signString =
                method.toUpperCase() + "\n" +
                path + "\n" +
                timestamp + "\n" +
                nonce + "\n" +
                bodyHash;

        // HMAC-SHA256
        String signature = hmacSha256(signString, secret);

        HttpClient client = HttpClient.newHttpClient();

        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(url))
                .header("Content-Type", "application/json")
                .header("X-Access-Key", accessKey)
                .header("X-Timestamp", timestamp)
                .header("X-Nonce", nonce)
                .header("X-Signature", signature)
                .POST(HttpRequest.BodyPublishers.ofString(
                        body,
                        StandardCharsets.UTF_8
                ))
                .build();

        HttpResponse<String> response = client.send(
                request,
                HttpResponse.BodyHandlers.ofString()
        );

        System.out.println(response.statusCode());
        System.out.println(response.body());
    }

    /**
     * SHA-256
     */
    private static String sha256(String data) throws Exception {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");

        byte[] hash = digest.digest(
                data.getBytes(StandardCharsets.UTF_8)
        );

        return hex(hash);
    }

    /**
     * HMAC-SHA256
     */
    private static String hmacSha256(
            String data,
            String secret
    ) throws Exception {

        Mac mac = Mac.getInstance("HmacSHA256");

        SecretKeySpec key = new SecretKeySpec(
                secret.getBytes(StandardCharsets.UTF_8),
                "HmacSHA256"
        );

        mac.init(key);

        byte[] hash = mac.doFinal(
                data.getBytes(StandardCharsets.UTF_8)
        );

        return hex(hash);
    }

    /**
     * 生成随机 nonce
     */
    private static String randomNonce() {
        byte[] bytes = new byte[16];
        new SecureRandom().nextBytes(bytes);
        return hex(bytes);
    }

    /**
     * byte[] -> 小写十六进制
     */
    private static String hex(byte[] bytes) {
        StringBuilder sb = new StringBuilder(bytes.length * 2);

        for (byte b : bytes) {
            sb.append(String.format("%02x", b));
        }

        return sb.toString();
    }
}

8. 实现时最容易出错的地方

8.1 Body 必须完全一致

下面两个 JSON 从业务角度可能表示相同数据,但签名不同:

{"username":"test001","nickname":"测试用户"}
{
  "username": "test001",
  "nickname": "测试用户"
}

因此应该:

  1. 先生成 JSON。
  2. 使用生成后的同一份字符串计算签名。
  3. 原样发送这份 Body。

8.2 Path 必须一致

例如:

/api/v1/game/user-create

和:

/api/v1/game/user-create/

签名不同。


8.3 Query 参数

QueryString 不参与当前签名。

例如:

/api/v1/game/user-list?page=1

当前签名中的 Path 是:

/api/v1/game/user-list

而不是:

/api/v1/game/user-list?page=1

8.4 Nonce 必须每次请求唯一

不要固定:

nonce = 123456

应该使用随机值,例如:

9f4a7c2e8b1d

或者 UUID。


8.5 Secret 不能泄露

不要把:

ApiSecret

放到:

  • 浏览器前端
  • Vue/React 前端代码
  • 微信小程序前端
  • App 可被逆向获取的位置
  • Git 仓库

如果调用方是浏览器/App 等不可信客户端,应通过调用方自己的后端服务完成签名。


Prev
api 对接流程(必看)
Next
api 接口