鉴权与签名
两段式: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,
而那个码看起来像「密钥抄错了」。
四条会让你调不通的细节:
- PATH 必须进签名串(含 query)。这是防「同一份 body 被重放到另一个端点」的
唯一手段 —— 少了它,一笔 /v1/transfers 的签名可以原样打到别处。
- 用 raw body 算哈希,不要重新序列化。 你的 JSON 库和我方的键顺序、空格、
Unicode 转义几乎一定不同,重新序列化出来的哈希对不上,而错误长得像「密钥不对」。
- 空 body 的 sha256 是定值:
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
x-timestamp是秒,容差 ±300 秒。机器时钟漂了就会全线 401。
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:
- 签名串是不是五段、有没有多一个结尾换行(
join不会加,
foreach 里逐行拼很容易多一个);
- 第二段有没有带上 query,且与真正发出去的 URL 逐字符一致;
- 第五段是不是 hex(64 个字符),
x-signature是不是 base64(44 个字符,
末尾一个 =);
x-timestamp是不是十位数(十三位就是毫秒)。