接入

请求签名

用 API Key 和 HMAC-SHA256 为每个开放支付 API 请求签名。

接口不使用 Bearer Token。每个请求携带你的 Key ID 和用 API Secret 计算出的 HMAC-SHA256 签名。

请求头

请求头 值
X-Key-Id API Key ID,如 ok_test_9f3a…
X-Timestamp 当前 Unix 时间,单位秒。与 HTP 服务器相差不能超过 5 分钟。
X-Nonce 16–64 位 [A-Za-z0-9_-],每个请求重新生成。同一 Key 下只能使用一次。
Idempotency-Key 8–64 位 [A-Za-z0-9_-]。POST /payments、POST /payments/{id}/refunds、POST /payouts 必填,其他接口可选。
X-Signature 签名,见下文。
Content-Type 有请求体时为 application/json。

计算签名

text
string_to_sign = METHOD          + "\n" +
                 PATH            + "\n" +
                 X-Timestamp     + "\n" +
                 X-Nonce         + "\n" +
                 Idempotency-Key + "\n" +
                 hex(sha256(body))

X-Signature = base64( HMAC-SHA256( api_secret, string_to_sign ) )
  • METHOD —— 大写:GET、POST。
  • PATH —— 路径含查询串,与实际发送的完全一致,不含协议和域名:/open/v1/orders?out_order_no=A1001&order_type=PAYMENT。
  • Idempotency-Key —— 不发送该请求头时这一行为空(字符串中出现 \n\n)。
  • body —— 实际发送的字节。JSON 只序列化一次并发送同一个字符串。GET 和空请求体对空字符串求哈希:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855。
  • hex 为小写;base64 为标准编码(含 +、/ 和 = 填充)。
  • Secret 按控制台展示的字符串以 UTF-8 字节使用。

检查你的实现

签名生成器可在浏览器中为任意输入生成 string_to_sign 和 X-Signature,方便与你的代码比对。

示例

text
METHOD          POST
PATH            /open/v1/payments
X-Timestamp     1700000000
X-Nonce         nonce-0123456789
Idempotency-Key idem-1234
body            {"a":1}
text
POST
/open/v1/payments
1700000000
nonce-0123456789
idem-1234
015abd7f5cc57a2dd94b7590f04ad8084273905ee33ec5cebeae62276a97f862

客户端代码

每段代码都是可直接复制使用的完整客户端,几乎没有外部依赖。

Node.js
import crypto from 'node:crypto';

const API_BASE = process.env.HTP_API_BASE ?? '';
const KEY_ID = process.env.HTP_KEY_ID;
const SECRET = process.env.HTP_API_SECRET;

export class HtpError extends Error {
  constructor(status, body) {
    super(body.message);
    this.status = status;
    this.code = body.error_code;
    this.traceId = body.trace_id;
  }
}

export async function htp(method, path, body, idempotencyKey = '') {
  const raw = body === undefined ? '' : JSON.stringify(body);
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const nonce = crypto.randomBytes(18).toString('base64url');
  const bodyHash = crypto.createHash('sha256').update(raw).digest('hex');
  const stringToSign = [method, path, timestamp, nonce, idempotencyKey, bodyHash].join('\n');
  const signature = crypto.createHmac('sha256', SECRET).update(stringToSign).digest('base64');

  const headers = {
    'Content-Type': 'application/json',
    'X-Key-Id': KEY_ID,
    'X-Timestamp': timestamp,
    'X-Nonce': nonce,
    'X-Signature': signature,
  };
  if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;

  const res = await fetch(API_BASE + path, { method, headers, body: raw || undefined });
  const json = await res.json();
  if (!res.ok) throw new HtpError(res.status, json);
  return json.data;
}
Python
import base64, hashlib, hmac, json, os, secrets, time
import requests

API_BASE = os.environ.get("HTP_API_BASE", "")
KEY_ID = os.environ["HTP_KEY_ID"]
SECRET = os.environ["HTP_API_SECRET"].encode()


class HtpError(Exception):
    def __init__(self, status, body):
        super().__init__(body.get("message"))
        self.status = status
        self.code = body.get("error_code")
        self.trace_id = body.get("trace_id")


def htp(method, path, body=None, idempotency_key=""):
    raw = b"" if body is None else json.dumps(body, separators=(",", ":")).encode()
    timestamp = str(int(time.time()))
    nonce = secrets.token_urlsafe(18)
    body_hash = hashlib.sha256(raw).hexdigest()
    string_to_sign = "\n".join([method, path, timestamp, nonce, idempotency_key, body_hash])
    signature = base64.b64encode(hmac.new(SECRET, string_to_sign.encode(), hashlib.sha256).digest()).decode()

    headers = {
        "Content-Type": "application/json",
        "X-Key-Id": KEY_ID,
        "X-Timestamp": timestamp,
        "X-Nonce": nonce,
        "X-Signature": signature,
    }
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key

    r = requests.request(method, API_BASE + path, headers=headers, data=raw or None, timeout=15)
    payload = r.json()
    if not r.ok:
        raise HtpError(r.status_code, payload)
    return payload["data"]
Go
package htp

import (
	"bytes"
	"context"
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"net/http"
	"strconv"
	"strings"
	"time"
)

type Client struct {
	BaseURL, KeyID, Secret string
	HTTP                   *http.Client
}

type Error struct {
	Status    int
	Code      string `json:"error_code"`
	Message   string `json:"message"`
	TraceID   string `json:"trace_id"`
}

func (e *Error) Error() string { return fmt.Sprintf("%s: %s (trace %s)", e.Code, e.Message, e.TraceID) }

func (c *Client) Do(ctx context.Context, method, path string, body any, idempotencyKey string, out any) error {
	var raw []byte
	if body != nil {
		var err error
		if raw, err = json.Marshal(body); err != nil {
			return err
		}
	}
	nb := make([]byte, 18)
	if _, err := rand.Read(nb); err != nil {
		return err
	}
	timestamp := strconv.FormatInt(time.Now().Unix(), 10)
	nonce := base64.RawURLEncoding.EncodeToString(nb)
	sum := sha256.Sum256(raw)
	stringToSign := strings.Join([]string{method, path, timestamp, nonce, idempotencyKey, hex.EncodeToString(sum[:])}, "\n")
	mac := hmac.New(sha256.New, []byte(c.Secret))
	mac.Write([]byte(stringToSign))

	req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, bytes.NewReader(raw))
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-Key-Id", c.KeyID)
	req.Header.Set("X-Timestamp", timestamp)
	req.Header.Set("X-Nonce", nonce)
	req.Header.Set("X-Signature", base64.StdEncoding.EncodeToString(mac.Sum(nil)))
	if idempotencyKey != "" {
		req.Header.Set("Idempotency-Key", idempotencyKey)
	}

	res, err := c.HTTP.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	var envelope struct {
		Data json.RawMessage `json:"data"`
	}
	if res.StatusCode >= 300 {
		e := &Error{Status: res.StatusCode}
		_ = json.NewDecoder(res.Body).Decode(e)
		return e
	}
	if err := json.NewDecoder(res.Body).Decode(&envelope); err != nil {
		return err
	}
	return json.Unmarshal(envelope.Data, out)
}
PHP
<?php
const HTP_API_BASE = '';

function htp(string $method, string $path, ?array $body = null, string $idempotencyKey = ''): array
{
    $raw = $body === null ? '' : json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    $timestamp = (string) time();
    $nonce = rtrim(strtr(base64_encode(random_bytes(18)), '+/', '-_'), '=');
    $stringToSign = implode("\n", [$method, $path, $timestamp, $nonce, $idempotencyKey, hash('sha256', $raw)]);
    $signature = base64_encode(hash_hmac('sha256', $stringToSign, getenv('HTP_API_SECRET'), true));

    $headers = [
        'Content-Type: application/json',
        'X-Key-Id: ' . getenv('HTP_KEY_ID'),
        'X-Timestamp: ' . $timestamp,
        'X-Nonce: ' . $nonce,
        'X-Signature: ' . $signature,
    ];
    if ($idempotencyKey !== '') {
        $headers[] = 'Idempotency-Key: ' . $idempotencyKey;
    }

    $ch = curl_init(HTP_API_BASE . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 15,
    ]);
    if ($raw !== '') {
        curl_setopt($ch, CURLOPT_POSTFIELDS, $raw);
    }
    $response = json_decode(curl_exec($ch), true);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    if ($status >= 300) {
        throw new RuntimeException("{$response['error_code']}: {$response['message']} (trace {$response['trace_id']})", $status);
    }
    return $response['data'];
}
Java
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.time.Instant;
import java.util.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public final class HtpClient {
    private final String baseUrl, keyId, secret;
    private final HttpClient http = HttpClient.newHttpClient();
    private final SecureRandom random = new SecureRandom();

    public HtpClient(String baseUrl, String keyId, String secret) {
        this.baseUrl = baseUrl; this.keyId = keyId; this.secret = secret;
    }

    /** body: JSON string or null. Returns the raw response JSON. */
    public HttpResponse<String> send(String method, String path, String body, String idempotencyKey) throws Exception {
        String raw = body == null ? "" : body;
        String idem = idempotencyKey == null ? "" : idempotencyKey;
        String timestamp = String.valueOf(Instant.now().getEpochSecond());
        byte[] nb = new byte[18];
        random.nextBytes(nb);
        String nonce = Base64.getUrlEncoder().withoutPadding().encodeToString(nb);
        String bodyHash = HexFormat.of().formatHex(
            MessageDigest.getInstance("SHA-256").digest(raw.getBytes(StandardCharsets.UTF_8)));
        String stringToSign = String.join("\n", method, path, timestamp, nonce, idem, bodyHash);
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        String signature = Base64.getEncoder().encodeToString(mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8)));

        HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(baseUrl + path))
            .header("Content-Type", "application/json")
            .header("X-Key-Id", keyId)
            .header("X-Timestamp", timestamp)
            .header("X-Nonce", nonce)
            .header("X-Signature", signature)
            .method(method, raw.isEmpty() ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(raw));
        if (!idem.isEmpty()) b.header("Idempotency-Key", idem);
        return http.send(b.build(), HttpResponse.BodyHandlers.ofString());
    }
}

常见签名错误

现象 原因
带查询串的 GET 返回 SIGNATURE_INVALID 签名的 PATH 没带查询串,或参数编码与实际发送的不同
GET 正常、POST 失败 签名后重新序列化了请求体(键顺序、空格、Unicode 转义发生变化)
只有 /close 失败 /close 没有请求体 —— 对空字符串求哈希;除非你也对 {} 签名,否则不要发送 {}
X-Timestamp is outside the allowed window 用了毫秒而不是秒,或服务器时钟偏差 —— 请使用 NTP
X-Nonce was already used 重试时复用了 nonce。重试需要新的 nonce 和签名,但使用相同的 Idempotency-Key
Idempotency-Key … is required 创建类接口缺少该请求头,或少于 8 位
创建新 Key 后失败 旧 Key 还有 24 小时有效期;确认 X-Key-Id 与签名用的 Secret 对应

密钥只能放在服务端

拿到 API Secret 的人可以从你的钱包发起退款和付款。绝不要把它放进网页、移动 App、代码仓库或日志。一旦泄露,请创建新 Key 并在控制台吊销旧 Key。