Skip to content

接口鉴权说明

为保障通信安全,所有对本平台 API 的请求都必须经过严格的身份验证。本文档将详细说明如何生成请求签名以及如何通过验证。

准备工作:获取 API 凭证

在开始之前,请登录您的商户管理面板,在 API 设置中获取以下两项关键凭证:

  • API_KEY:您的唯一身份标识符。
  • SECRET_KEY:用于计算签名的私钥,请务必妥善保管,切勿泄露

鉴权流程核心

鉴权核心是客户端使用 SECRET_KEY 对请求的关键信息生成一个签名 (signature),然后将 API_KEYsignature 及其他辅助信息通过 HTTP 请求头 (Headers) 发送给服务器。服务器会以完全相同的方式在本地重新计算签名,并与客户端传来的签名进行比对,若一致则验证通过。

构建待签名字符串

签名过程的第一步是按照以下规则,将请求的多个部分组合成一个单一的、规范化的字符串。我们称之为 stringToSign

stringToSign 的结构如下,各部分之间以换行符 \n 分隔:

REQUEST_BODY\n
TIMESTAMP\n
NONCE

各组成部分的详细规则:

变量说明规则与说明
REQUEST_BODY请求体HTTP 请求的 Body 内容原文。注意事项:1. 对于 GET 等无请求体的请求,此部分为空字符串。2. 对于 POSTPUT 请求,此部分为请求体的原始字节流。请确保不对其进行任何修改(如添加/删除空格、改变JSON字段顺序等)。
TIMESTAMP时间戳当前的 Unix 时间戳(秒级),表示为字符串。服务器会拒绝与服务器时间相差超过 ±5分钟 的请求,以防止重放攻击。
NONCE随机数一个只使用一次的随机字符串,用于防止重放攻击。要求:1. 建议使用 UUID 或其他高强度随机字符串生成。2. 对于同一个 API_KEY,在 TIMESTAMP 定义的有效时间窗口内,NONCE 必须是唯一的。服务器会拒绝处理已经接收过的 NONCE

重要提示

构建 stringToSign 时,请确保所有文本内容均使用 UTF-8 编码。

计算签名

  1. 使用 HMAC-SHA256 算法,以您的 SECRET_KEY 作为密钥,对上一步构建的 stringToSign 进行哈希计算。
  2. 将计算得到的二进制哈希值进行 十六进制 (Hex) 编码。
  3. 将编码后的十六进制字符串转换为小写,得到最终的 signature

伪代码示例:

python
signature = hmac_sha256(secret_key, stringToSign).hex().lower()

发送 API 请求

将以下鉴权信息放入 HTTP 请求的 Header 中:

Header 字段来源
X-Api-Key您的 API_KEY来自商户面板
X-Timestamp使用的时间戳stringToSign 中的 TIMESTAMP 保持一致
X-Nonce使用的随机数stringToSign 中的 NONCE 保持一致
X-Signature计算出的签名signature

完整示例

假设我们有以下凭证和请求信息:

  • API_KEY: 3AUpfeK573UH5vVe
  • SECRET_KEY: 5ShtY7nXAT8Wm2RBeKLv7iPakVyxjddU
  • 请求:
http
POST /openapi/v1/payment HTTP/1.1
Content-Type: application/json

{"order_no":"Pay1754574105","chain_type":"bsc","order_amount":"1","product_name":"Test product name","notify_url":"http://api.example.com/my-notify-url","redirect_url":"","meta":""}

构建 stringToSign

  1. REQUEST_BODY: {"order_no":"Pay1754574105","chain_type":"bsc","order_amount":"1","product_name":"Test product name","notify_url":"http://api.example.com/my-notify-url","redirect_url":"","meta":""}
  2. TIMESTAMP: 假设为 1754574105
  3. NONCE: 假设为 random_nonce_str

将以上各部分用 \n 拼接,得到 stringToSign (为了可读性,这里的 \n 仅作示意):

text
{"order_no":"Pay1754574105","chain_type":"bsc","order_amount":"1","product_name":"Test product name","notify_url":"http://api.example.com/my-notify-url","redirect_url":"","meta":""}\n1754574105\nrandom_nonce_str

计算 signature

使用 HMAC-SHA256 算法,以 5ShtY7nXAT8Wm2RBeKLv7iPakVyxjddU 为密钥,对上述 stringToSign 进行计算。得到的签名为 ce4f73fcc17722e053f7315bfa48384bc50e579ec760e71fa91a6f7cf0d24bfa

组装并发送请求

bash
POST /openapi/v1/payment HTTP/1.1
Host: api.zaepe.com
Content-Type: application/json
X-Api-Key: 3AUpfeK573UH5vVe
X-Nonce: random_nonce_str
X-Signature: ce4f73fcc17722e053f7315bfa48384bc50e579ec760e71fa91a6f7cf0d24bfa
X-Timestamp: 1754574105

{"order_no":"Pay1754574105","chain_type":"bsc","order_amount":"1","product_name":"Test product name","notify_url":"http://api.example.com/my-notify-url","redirect_url":"","meta":""}

参考代码

Golang

go
package apisign

import (
  "bytes"
  "crypto/hmac"
  "crypto/sha256"
  "encoding/hex"
  "io"
  "net/http"
  "strconv"
  "strings"
  "time"

  "github.com/cockroachdb/errors"
)

const (
  HeaderAPIKey    = "X-Api-Key"
  HeaderTimestamp = "X-Timestamp"
  HeaderNonce     = "X-Nonce"
  HeaderSignature = "X-Signature"

  // DefaultExpiration defines the default expiration time for a signature.
  DefaultExpiration = 30 * time.Second
)

type SignError struct {
  Field string
  Err   error
}

func (e *SignError) Error() string {
  return e.Field + ": " + e.Err.Error()
}

func Verify(req *http.Request, secret string) error {
  if key := req.Header.Get(HeaderAPIKey); key == "" {
    return &SignError{Field: HeaderAPIKey, Err: errors.New("empty api key")}
  }

  timestamp := req.Header.Get(HeaderTimestamp)
  if timestamp == "" {
    return &SignError{Field: HeaderTimestamp, Err: errors.New("empty timestamp")}
  }
  unix, err := strconv.ParseInt(timestamp, 10, 64)
  if err != nil {
    return &SignError{Field: HeaderTimestamp, Err: errors.New("invalid timestamp")}
  }
  t := time.Unix(unix, 0)
  if time.Since(t).Abs() > DefaultExpiration {
    return &SignError{Field: HeaderTimestamp, Err: errors.New("timestamp expired")}
  }

  nonce := req.Header.Get(HeaderNonce)
  if nonce == "" {
    return &SignError{Field: HeaderNonce, Err: errors.New("empty nonce")}
  }

  signature := strings.ToLower(req.Header.Get(HeaderSignature))
  if signature == "" {
    return &SignError{Field: HeaderSignature, Err: errors.New("empty signature")}
  }

  b, err := io.ReadAll(req.Body)
  if err != nil {
    return &SignError{Field: HeaderSignature, Err: errors.Wrap(err, "fail to read body")}
  }
  req.Body = io.NopCloser(bytes.NewReader(b))

  sign := Sign(secret, b, t, nonce)

  if !hmac.Equal([]byte(sign), []byte(signature)) {
    return &SignError{Field: HeaderSignature, Err: errors.New("invalid signature")}
  }

  return nil
}

// Sign generates a signature for the given request body, timestamp, and nonce.
// The signature is an HMAC-SHA256 hash of the request body, timestamp, and nonce, separated by newlines.
func Sign(secretKey string, requestBody []byte, t time.Time, nonce string) string {
  // For debugging, you can easily print the content to be signed.
  // log.Printf("signing content: %s", string(content))
  content := bytes.Join(
    [][]byte{
      requestBody,
      []byte(strconv.FormatInt(t.Unix(), 10)),
      []byte(nonce),
    },
    []byte("\n"),
  )

  mac := hmac.New(sha256.New, []byte(secretKey))
  mac.Write(content)
  return hex.EncodeToString(mac.Sum(nil))
}

PHP

php
class SignError extends \Exception
{
    /**
     * @var string
     */
    protected $field;

    public function __construct(string $field, string $message = "", int $code = 0, \Throwable $previous = null)
    {
        parent::__construct($message, $code, $previous);
        $this->field = $field;
    }

    public function getField(): string
    {
        return $this->field;
    }

    public function __toString(): string
    {
        return $this->field . ": " . $this->getMessage();
    }
}

class ApiSigner
{

    public static function verify(\Psr\Http\Message\ServerRequestInterface $request, string $secret): bool
    {
        $key = $request->getHeaderLine('X-Api-Key');
        if (empty($key)) {
            throw new SignError("X-Api-Key", "empty api key");
        }

        $timestamp = $request->getHeaderLine('X-Timestamp');
        if (empty($timestamp)) {
            throw new SignError("X-Timestamp", "empty timestamp");
        }
        $unixTimestamp = (int)$timestamp;
        if ((string)$unixTimestamp !== $timestamp) {
             throw new SignError("X-Timestamp", "invalid timestamp");
        }

        $nonce = $request->getHeaderLine('X-Nonce');
        if (empty($nonce)) {
            throw new SignError("X-Nonce", "empty nonce");
        }

        $signature = strtolower($request->getHeaderLine('X-Signature'));
        if (empty($signature)) {
            throw new SignError("X-Signature", "empty signature");
        }

        $body = (string)$request->getBody();

        $request->getBody()->rewind();

        $sign = self::sign(
            $secret,
            $body,
            $unixTimestamp,
            $nonce
        );

        if ($sign !== $signature) {
            throw new SignError("X-Signature", "invalid signature");
        }

        return true;
    }

    public static function sign(
        string $secretKey,
        string $requestBody,
        int $timestamp,
        string $nonce
    ): string {
        $data = implode("\n", [
            $requestBody,
            (string)$timestamp,
            $nonce,
        ]);

        return hash_hmac('sha256', $data, $secretKey);
    }
}

常见问题

请严格按照以下顺序排查:

  1. SECRET_KEY 是否正确无误?
  2. stringToSign拼接顺序是否完全正确?
  3. REQUEST_BODY 是否与实际发送的字节完全一致?(注意检查多余的空格或换行)
  4. TIMESTAMP 是否是秒级时间戳,且在服务器允许的时间窗口内?
  5. 所有参与拼接的字符串是否都使用了 UTF-8 编码?