One Key contains two values—one obtains tokens, the other verifies signatures. PATH must be included in the signing string.
Authentication and Signing
There are two stages: exchange an API Key for a token, then use the token for business requests. Write requests also require a signature.
Stage 1: Obtain a Token
Creating an API Key returns two values with different purposes. Do not confuse them:
| Value | Purpose | Stored representation |
|---|---|---|
api_key | Obtain a token | Hash only; shown once at creation and cannot be retrieved afterward |
signing_key | Verify request signatures (HMAC) | Retrievable; both sides need the original symmetric key |
Credentials go in the request headers, not the body. This request has no body.
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", …] }
Tokens are valid for 30 minutes. Obtain another before expiry. We do not provide refresh tokens: obtaining a token is already a lightweight call, and a separate refresh flow would introduce another expiring credential.
Stage 2: Call Business Endpoints
x-auth-token: Bearer <token> # 也收 authorization: Bearer <token>
x-on-behalf-of: <会员标识> # 只有「代会员」的端点要
The badges at the top of each endpoint page indicate whether x-on-behalf-of is required.
⚠ Do not send a merchant ID. Your merchant identity comes from the token, which was obtained using your API Key.
Earlier versions of this guide listed
x-zise-merchant; nothing in our system reads that header.Sending an unused header is harmless by itself, but when investigating a 401 it can appear to be a relevant clue
even though it has no effect.
Signatures
Write requests require signatures by default. Signing cannot be disabled for POST /v1/deposits, the only endpoint in the system that directly creates member balances.
⚠ A write request means any method other than
GETorHEAD, regardless of whether it has a body.
DELETEalso requires signing: the body is an empty string, andsha256_hex("")still belongs in the signing string.A missing signature returns
401, which can look like a credentials problem.If your
POSTrequests work, this can easily send your investigation in the wrong direction.Idempotency keys follow different rules: only writes that create a new object require them.
Deletion and inherently idempotent actions such as setting a default do not require one; that would add an unnecessary step.
Each endpoint page specifies its requirement.
The signing string has five components, joined with \n:
METHOD
/v1/path?with=query
x-timestamp
x-nonce
sha256_hex(raw_body)
x-signature = base64(HMAC-SHA256(signing_key, 签名串)).
⚠ Encode the HMAC result as base64, but encode the body SHA-256 as hex. These use different encodings—the easiest detail to miss on this page. A hex HMAC has the wrong length: x-signature should contain 44 characters, whereas hex contains 64. The response is invalid_signature, which can look like a mistyped key.
Four details that can prevent requests from working:
- PATH must be included in the signing string, including the query. This is the only protection against replaying the same body at another endpoint; without it, a signature for
/v1/transferscould be reused elsewhere. - Hash the raw body; do not serialize it again. Your JSON library will almost certainly differ from ours in key ordering, whitespace, or Unicode escaping. Reserialization produces a different hash, although the error resembles an incorrect key.
- The SHA-256 of an empty body is constant:
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 x-timestampuses seconds, with a tolerance of ±300 seconds. Clock drift can cause every request to return 401.
Use a new x-nonce for each request. We store it in the database and prohibit reuse for 5 minutes. Nonce consumption occurs after signature verification; otherwise an attacker could guess a nonce and consume your legitimate value before you use it.
Environments
The environment is determined by the domain you call, not a request header.
| Environment | Domain |
|---|---|
| Production | https://api.zinfra.vip |
| Sandbox | https://api.zinfra.dev |
Credentials used across environments are rejected with the specific code environment_mismatch, rather than a generic invalid-credentials error. Using a sandbox Key in production is a common integration mistake; distinguishing it from a misconfigured key saves an unnecessary round of troubleshooting.
IP Allowlists
Keys in the Live environment must have a nonempty allowlist. Each Key has its own list, entered one item per line in the merchant portal. An entry may be a single IP, a wildcard prefix (203.0.113.*), or CIDR—but CIDR supports only /8, /16, and /24.
⚠ Unrecognized formats never match: rejecting a legitimate request is preferable to admitting an unauthorized one. An invalid format therefore causes all requests to return 403, rather than disabling the allowlist. This is deliberate; disabling it would create a silent security gap. Entries using /28 or /32 fall into this category: they do not raise a configuration error, but admit no requests. Use a single IP or broaden the range to /24.
Rejected requests return ip_not_allowed. The response does not echo your configuration, which would disclose the allowlist to the caller. View it under Developer → API Keys in the merchant portal.
Implementations by Language
The following four signing implementations can be copied and run directly. Each performs the same four steps: construct the signing string → sha256(rawBody) → HMAC-SHA256 → populate request headers.
The four mistakes that raise no local error but always produce 401 are highlighted in comments in each implementation:
| Pitfall | Symptom |
|---|---|
| Include PATH and query in the signing string | Endpoints with a query always return invalid_signature; those without one work |
| Hash the raw body without reserializing it | Apparently random failures: any difference in key order, whitespace, or Unicode escaping produces a mismatch |
| Use a timestamp in seconds, not milliseconds | Every request returns timestamp_out_of_range (tolerance ±300 seconds) |
| Generate a new nonce each time | The first request succeeds, but a retry returns nonce_reused—just when you need it to work |
Node.js
No third-party dependencies (Node 18+).
import crypto from "node:crypto";
const BASE = "https://api.zinfra.dev";
/// 签名串五段,换行连接,顺序不可换。
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
Standard library + requests.
import base64, hashlib, hmac, json, time, uuid
import requests
BASE = "https://api.zinfra.dev"
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
Standard library + 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.zinfra.dev"
// 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
Standard library + cURL (PHP 7.2+).
<?php
const ZISE_BASE = 'https://api.zinfra.dev';
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)];
}
When Signatures Do Not Match
First use the signature debugger to compare the signing string character by character. It runs locally in your browser; signing_key never leaves your computer.
Check the details least likely to resemble a key problem first. Every item in the table above can manifest as invalid_signature:
- Does the signing string contain exactly five components, with no extra trailing newline?
joindoes not add one, but concatenating lines insideforeacheasily can. - Does the second component include the query and match the actual outgoing URL character for character?
- Is the fifth component hex (64 characters), and is
x-signaturebase64 (44 characters, ending in one=)? - Is
x-timestampten digits long? Thirteen digits means milliseconds.