Z Zise Developers
账户中心 › 指南

鉴权与签名

两段式:API Key 换令牌,令牌调业务。写请求还要再过一道签名。

第一段:换令牌

一把 API Key 建出来的时候下发两个值,用途不同,别混:

用途我方存的形态
api_key换令牌只存哈希,创建时展示一次,之后取不回来
signing_key请求验签(HMAC)可取回(对称密钥,两边都要有原文)

凭据走请求头,不在请求体里 —— 这一步没有请求体。


POST /v1/connect/token
x-client-id: zc_live_7f3a9b2e4c1d
x-api-key: sk_live_…

→ 200
{ "auth_token": "eyJ…", "expired_at": 1754872200,
  "token_type": "Bearer", "scopes": ["members:read", …] }

令牌有效期 30 分钟。过期前换一把新的即可,我方不提供 refresh —— 换令牌本身

就是一次轻量调用,再加一条 refresh 链路只会多一个会过期的东西。

第二段:调业务


authorization: Bearer <token>
x-zise-merchant: <你的商户短码>
x-on-behalf-of: <会员标识>      # 只有「代会员」的端点要

哪些端点要 x-on-behalf-of,每个端点页顶部的徽章上写着。

签名

写请求默认强制签名POST /v1/deposits 的签名不可关闭——

它是全系统唯一一个凭空产生会员余额的端点。

签名串是五段,用 \n 连接:


METHOD
/v1/path?with=query
x-timestamp
x-nonce
sha256_hex(raw_body)

x-signature = base64(HMAC-SHA256(signing_key, 签名串))

HMAC 的结果编 base64,body 的 sha256 编 hex —— 两段编码不一样,这是本页

最容易看漏的一处。HMAC 输出成 hex 再送上来长度都不对(x-signature 应当是

44 个字符,hex 是 64 个),拿到的是 invalid_signature

而那个码看起来像「密钥抄错了」。

四条会让你调不通的细节:

唯一手段 —— 少了它,一笔 /v1/transfers 的签名可以原样打到别处。

Unicode 转义几乎一定不同,重新序列化出来的哈希对不上,而错误长得像「密钥不对」。

e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

x-nonce 每次换新,我方落库 5 分钟内不可二用。nonce 的消费发生在验签之后——

放在之前的话,任何人拿一个猜的 nonce 就能把你的合法 nonce 提前烧掉。

环境

判据是你打的域名,不是请求头。

环境域名
生产https://api.zise.com
沙盒https://api-sandbox.zise.com

跨环境凭据一律拒,且返回专门的码 environment_mismatch,不是笼统的

「凭据无效」—— 沙盒 Key 打到生产上是接入期最常见的一次卡壳,把它和「密钥配错了」

分开能省掉一轮排查。

IP 白名单

Live 环境的 Key 强制非空。一把 Key 收一份清单(在商户后台里一行一条),

每条可以是:单 IP、前缀通配(203.0.113.*)、或 CIDR —— 但 CIDR **只认

/8 /16 /24 这三档**。

认不出的写法一律不匹配(宁可漏放不可错放)。所以填错格式的表现是

「全部请求 403」,而不是「白名单没生效」—— 这是刻意的:后者是个静默的洞。

写成 /28/32 就落在这一档:它不会报错,只会一条都不放行。

要么写成单 IP,要么放宽到 /24

被拒时返回 ip_not_allowed,响应里不回显你配了什么 —— 那等于把白名单

念给调用方听。到商户后台的「开发者 → API Keys」里看。

逐语言实现

四段可以直接粘贴运行的签名实现。每段都做同样四件事:拼签名串 →

sha256(rawBody) → HMAC-SHA256 → 装进请求头。

四处写错了不会报错、只会一直 401 的地方,在每段代码里都用注释标了出来:

写错的表现
PATH 含 query 必须进签名串带 query 的端点全线 invalid_signature,不带的却好好的
用 raw body 算哈希,不要重新序列化随机失败:键顺序、空格、Unicode 转义任一处不同就恒不匹配
时间戳是不是毫秒全线 timestamp_out_of_range(容差 ±300 秒)
nonce 每次换新第一笔成,重试那笔 nonce_reused —— 而重试正是你最需要它成的时候

Node.js

无第三方依赖(Node 18+)。


import crypto from "node:crypto";

const BASE = "https://api-sandbox.zise.com";

/// 签名串五段,换行连接,顺序不可换。
function signHeaders({ method, pathWithQuery, rawBody, signingKey }) {
  // ① 秒,不是毫秒。Date.now() 是毫秒 —— 少了这个 /1000 会全线 401
  //    timestamp_out_of_range,而它长得完全不像时钟问题。
  const timestamp = String(Math.floor(Date.now() / 1000));
  // ② nonce 每次换新。复用一把(比如放进常量、或跟着重试原样重发)
  //    的表现是:第一次成功,重试那次 nonce_reused。
  const nonce = crypto.randomUUID();
  // ③ 对 raw body 的字节算 sha256,编 hex。空 body 也照算
  //    (结果就是文档里那个定值 e3b0c4…b855)。
  const bodyHash = crypto.createHash("sha256").update(rawBody, "utf8").digest("hex");
  // ④ 第二段是 PATH + query string。少了 query,带 ?limit=20 的端点
  //    会全线 invalid_signature,而不带 query 的端点一切正常 ——
  //    这个「时好时坏」的形态最难查。
  const msg = [method.toUpperCase(), pathWithQuery, timestamp, nonce, bodyHash].join("\n");
  // ⑤ HMAC 编 base64(body 哈希编 hex,两处不一样)。
  const signature = crypto.createHmac("sha256", signingKey).update(msg, "utf8").digest("base64");
  return { "x-timestamp": timestamp, "x-nonce": nonce, "x-signature": signature };
}

export async function call({ token, signingKey, method, pathWithQuery, payload }) {
  // ⑥ 只序列化这一次,之后哈希和发送用的是同一个字符串。
  //    把 payload 交给 fetch 的 body 再让它自己 JSON.stringify 一遍,
  //    或者哈希算完又改了一个字段,都会让签名恒不匹配。
  const rawBody = payload === undefined ? "" : JSON.stringify(payload);
  const res = await fetch(BASE + pathWithQuery, {
    method,
    headers: {
      "content-type": "application/json",
      "x-auth-token": `Bearer ${token}`,
      // 写请求必带,UUID。重试时**沿用同一把**(换新键 == 第二笔真实付款)。
      "x-idempotency-key": crypto.randomUUID(),
      ...signHeaders({ method, pathWithQuery, rawBody, signingKey }),
    },
    body: rawBody === "" ? undefined : rawBody,
  });
  return { status: res.status, body: await res.json() };
}

Python

标准库 + requests


import base64, hashlib, hmac, json, time, uuid
import requests

BASE = "https://api-sandbox.zise.com"


def call(token, signing_key, method, path_with_query, payload=None):
    # ① 只序列化这一次。**不要用 requests 的 json= 参数** —— 那会让
    #    requests 自己再序列化一遍(默认带空格:{"a": 1} 而不是 {"a":1}),
    #    于是你哈希的字节和实际发出去的字节不是同一份,签名恒不匹配。
    #    用 data= 发这份字符串的字节。
    raw_body = "" if payload is None else json.dumps(
        payload, separators=(",", ":"), ensure_ascii=False
    )

    # ② 秒。time.time() 是浮点秒,int() 掉小数;写成 time.time()*1000
    #    就是全线 timestamp_out_of_range。
    ts = str(int(time.time()))
    # ③ 每次换新。
    nonce = str(uuid.uuid4())
    # ④ raw body 的 sha256,hex。
    body_hash = hashlib.sha256(raw_body.encode("utf-8")).hexdigest()
    # ⑤ 第二段是 PATH + query。path_with_query 传进来时就要带上
    #    "?limit=20" 这一截,且要和真正发出去的 URL 逐字符一致
    #    (包括参数顺序和百分号编码)。
    msg = "\n".join([method.upper(), path_with_query, ts, nonce, body_hash])
    # ⑥ HMAC 编 base64(body 哈希编 hex)。
    sig = base64.b64encode(
        hmac.new(signing_key.encode("utf-8"), msg.encode("utf-8"), hashlib.sha256).digest()
    ).decode("ascii")

    return requests.request(
        method,
        BASE + path_with_query,
        headers={
            "content-type": "application/json",
            "x-auth-token": f"Bearer {token}",
            "x-idempotency-key": str(uuid.uuid4()),
            "x-timestamp": ts,
            "x-nonce": nonce,
            "x-signature": sig,
        },
        data=raw_body.encode("utf-8"),
        timeout=30,
    )

Go

标准库 + github.com/google/uuid


package zise

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

	"github.com/google/uuid"
)

const base = "https://api-sandbox.zise.com"

// Call 发一次已签名的请求。
//
// ⚠ rawBody 是 []byte 而不是 interface{}:调用方自己 json.Marshal 一次,
// 之后哈希与发送用的是同一份字节。签名函数里再 Marshal 一遍是这条链上
// 最常见的错 —— Go 的 map 键序不稳定,同一个 struct 两次 Marshal 也可能
// 因为字段变动而不同,而错误长得像「密钥不对」。
func Call(token, signingKey, method, pathWithQuery string, rawBody []byte) (*http.Response, error) {
	// ① 秒。time.Now().Unix() 就是秒;UnixMilli() 会全线 timestamp_out_of_range。
	ts := strconv.FormatInt(time.Now().Unix(), 10)
	// ② 每次换新。
	nonce := uuid.NewString()
	// ③ raw body 的 sha256,hex。空 body 照算。
	sum := sha256.Sum256(rawBody)
	bodyHash := hex.EncodeToString(sum[:])
	// ④ 第二段是 PATH + query。用 req.URL.RequestURI() 取也行,但要保证
	//    它与签名时用的是同一个串 —— 不要一边手写路径、一边让 http 库
	//    重新拼 query。
	msg := strings.Join([]string{
		strings.ToUpper(method), pathWithQuery, ts, nonce, bodyHash,
	}, "\n")
	// ⑤ HMAC 编 base64。
	mac := hmac.New(sha256.New, []byte(signingKey))
	mac.Write([]byte(msg))
	sig := base64.StdEncoding.EncodeToString(mac.Sum(nil))

	var body io.Reader
	if len(rawBody) > 0 {
		body = bytes.NewReader(rawBody)
	}
	req, err := http.NewRequest(strings.ToUpper(method), base+pathWithQuery, body)
	if err != nil {
		return nil, err
	}
	req.Header.Set("content-type", "application/json")
	req.Header.Set("x-auth-token", "Bearer "+token)
	req.Header.Set("x-idempotency-key", uuid.NewString())
	req.Header.Set("x-timestamp", ts)
	req.Header.Set("x-nonce", nonce)
	req.Header.Set("x-signature", sig)
	return http.DefaultClient.Do(req)
}

PHP

标准库 + cURL(PHP 7.2+)。


<?php

const ZISE_BASE = 'https://api-sandbox.zise.com';

function zise_uuid4(): string {
    $b = random_bytes(16);
    $b[6] = chr((ord($b[6]) & 0x0f) | 0x40);
    $b[8] = chr((ord($b[8]) & 0x3f) | 0x80);
    return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4));
}

/**
 * ⚠ $rawBody 收的是**字符串**,不是数组。调用方自己 json_encode 一次,
 *   之后哈希与发送用同一份字节。传数组进来再在函数里 encode 一遍,
 *   等到哪天有人给 CURLOPT_POSTFIELDS 换了别的编码方式就恒不匹配了。
 */
function zise_call(string $token, string $signingKey, string $method,
                   string $pathWithQuery, string $rawBody = ''): array {
    // ① 秒。PHP 的 time() 就是秒;microtime(true)*1000 会全线
    //    timestamp_out_of_range。
    $ts = (string) time();
    // ② 每次换新。
    $nonce = zise_uuid4();
    // ③ raw body 的 sha256,hex(hash() 默认就是 hex)。
    $bodyHash = hash('sha256', $rawBody);
    // ④ 第二段是 PATH + query,与真正请求的 URL 逐字符一致。
    $msg = implode("\n", [strtoupper($method), $pathWithQuery, $ts, $nonce, $bodyHash]);
    // ⑤ HMAC 编 base64。**第四个参数 true 不能省** —— 它是「返回原始
    //    二进制」的开关。省掉它拿到的是 64 个字符的 hex 串,再 base64
    //    一次就是 88 个字符(正确的是 44 个)送上来,服务端一律
    //    invalid_signature —— 而那个码看起来像密钥配错了。
    //    这是 PHP 这一侧最常见的一处。
    $sig = base64_encode(hash_hmac('sha256', $msg, $signingKey, true));

    $ch = curl_init(ZISE_BASE . $pathWithQuery);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => strtoupper($method),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 30,
        CURLOPT_POSTFIELDS => $rawBody,
        CURLOPT_HTTPHEADER => [
            'content-type: application/json',
            'x-auth-token: Bearer ' . $token,
            'x-idempotency-key: ' . zise_uuid4(),
            'x-timestamp: ' . $ts,
            'x-nonce: ' . $nonce,
            'x-signature: ' . $sig,
        ],
    ]);
    $resp = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    return ['status' => $status, 'body' => json_decode((string) $resp, true)];
}

对不上的时候

先用签名调试器逐字符比对签名串。它在你的浏览器里本地算,

signing_key 不会离开这台机器。

比对的顺序按「最不像密钥问题的排在最前」来 —— 上面那张表的四条,

每一条的表现都是 invalid_signature

foreach 里逐行拼很容易多一个);

末尾一个 =);