Z Zise Developers 简体中文
Account Center › Guides

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:

ValuePurposeStored representation
api_keyObtain a tokenHash only; shown once at creation and cannot be retrieved afterward
signing_keyVerify 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 GET or HEAD, regardless of whether it has a body.

DELETE also requires signing: the body is an empty string, and sha256_hex("") still belongs in the signing string.

A missing signature returns 401, which can look like a credentials problem.

If your POST requests 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:

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.

EnvironmentDomain
Productionhttps://api.zinfra.vip
Sandboxhttps://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:

PitfallSymptom
Include PATH and query in the signing stringEndpoints with a query always return invalid_signature; those without one work
Hash the raw body without reserializing itApparently random failures: any difference in key order, whitespace, or Unicode escaping produces a mismatch
Use a timestamp in seconds, not millisecondsEvery request returns timestamp_out_of_range (tolerance ±300 seconds)
Generate a new nonce each timeThe 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: