快速开始

快速开始

约 15 分钟完成第一笔沙箱收款。

示例代码提供 Node.js、Python、Go、Java 和 PHP 版本。在任意代码标签中选择你的语言,所有步骤都会同步切换。

获取沙箱凭证

在开发者控制台中:

  1. 创建 App 并申请开通支付(App 未发布也可使用沙箱)。
  2. 支付状态变为已开通后,进入支付 → API Key,创建沙箱 Key。复制 key_id(ok_test_…)和 Secret —— Secret 只展示一次。
  3. 设置回调地址,复制回调签名密钥(同样只展示一次)。

保存为环境变量:

bash
export HTP_API_BASE=https://htp-open-api.vai247.pro
export HTP_KEY_ID=ok_test_xxxxxxxx
export HTP_API_SECRET=xxxxxxxxxxxxxxxx
export HTP_CALLBACK_SECRET=xxxxxxxxxxxxxxxx

添加签名函数

每个请求都使用 HMAC-SHA256 签名。将下面的辅助函数保存到你的项目中(文件名见第一行):

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

const { HTP_API_BASE, HTP_KEY_ID, HTP_API_SECRET } = process.env;

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'); // 24 URL-safe chars
  const bodyHash = crypto.createHash('sha256').update(raw).digest('hex');
  const stringToSign = [method, path, timestamp, nonce, idempotencyKey, bodyHash].join('\n');
  const signature = crypto.createHmac('sha256', HTP_API_SECRET).update(stringToSign).digest('base64');

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

  const res = await fetch(HTP_API_BASE + path, { method, headers, body: raw || undefined });
  const json = await res.json();
  if (!res.ok) {
    throw Object.assign(new Error(json.message), { code: json.error_code, status: res.status, traceId: json.trace_id });
  }
  return json.data;
}
Python
# htp.py  (pip install requests)
import base64, hashlib, hmac, json, os, secrets, time
import requests

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


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)  # 24 URL-safe chars
    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

    res = requests.request(method, API_BASE + path, headers=headers, data=raw or None, timeout=15)
    payload = res.json()
    if not res.ok:
        raise RuntimeError(f"{payload['error_code']}: {payload['message']} (trace {payload['trace_id']})")
    return payload["data"]
Go
// htp.go
package main

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

func htp(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
		}
	}
	timestamp := strconv.FormatInt(time.Now().Unix(), 10)
	nb := make([]byte, 18)
	rand.Read(nb)
	nonce := base64.RawURLEncoding.EncodeToString(nb) // 24 URL-safe chars
	sum := sha256.Sum256(raw)
	stringToSign := strings.Join([]string{method, path, timestamp, nonce, idempotencyKey, hex.EncodeToString(sum[:])}, "\n")
	mac := hmac.New(sha256.New, []byte(os.Getenv("HTP_API_SECRET")))
	mac.Write([]byte(stringToSign))
	signature := base64.StdEncoding.EncodeToString(mac.Sum(nil))

	req, err := http.NewRequest(method, os.Getenv("HTP_API_BASE")+path, bytes.NewReader(raw))
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-Key-Id", os.Getenv("HTP_KEY_ID"))
	req.Header.Set("X-Timestamp", timestamp)
	req.Header.Set("X-Nonce", nonce)
	req.Header.Set("X-Signature", signature)
	if idempotencyKey != "" {
		req.Header.Set("Idempotency-Key", idempotencyKey)
	}

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	data, _ := io.ReadAll(res.Body)
	var env struct {
		Data      json.RawMessage `json:"data"`
		ErrorCode string          `json:"error_code"`
		Message   string          `json:"message"`
		TraceID   string          `json:"trace_id"`
	}
	if err := json.Unmarshal(data, &env); err != nil {
		return err
	}
	if res.StatusCode >= 300 {
		return fmt.Errorf("%s: %s (trace %s)", env.ErrorCode, env.Message, env.TraceID)
	}
	return json.Unmarshal(env.Data, out)
}
Java
// Htp.java  (Java 17+)
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 Htp {
    private static final HttpClient HTTP = HttpClient.newHttpClient();
    private static final SecureRandom RANDOM = new SecureRandom();

    /** body: a JSON string, or null. Returns the response JSON. */
    public static String call(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); // 24 URL-safe chars
        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(System.getenv("HTP_API_SECRET").getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        String signature = Base64.getEncoder().encodeToString(mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8)));

        HttpRequest.Builder req = HttpRequest.newBuilder(URI.create(System.getenv("HTP_API_BASE") + path))
            .header("Content-Type", "application/json")
            .header("X-Key-Id", System.getenv("HTP_KEY_ID"))
            .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()) req.header("Idempotency-Key", idem);

        HttpResponse<String> res = HTTP.send(req.build(), HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() >= 300) throw new RuntimeException("HTTP " + res.statusCode() + ": " + res.body());
        return res.body(); // {"data": {...}} — parse with Jackson, Gson, etc.
    }
}
PHP
<?php
// htp.php
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)), '+/', '-_'), '='); // 24 URL-safe chars
    $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(getenv('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);
    }
    $res = json_decode(curl_exec($ch), true);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    if ($status >= 300) {
        throw new RuntimeException("{$res['error_code']}: {$res['message']} (trace {$res['trace_id']})", $status);
    }
    return $res['data'];
}

创建收款单

Node.js
// create-payment.js
import crypto from 'node:crypto';
import { htp } from './htp.js';

const orderNo = 'A' + Date.now();
const payment = await htp(
  'POST',
  '/open/v1/payments',
  { out_order_no: orderNo, amount: '1.00', currency: 'USDT', description: 'Test order' },
  crypto.randomUUID().replaceAll('-', ''), // Idempotency-Key — reuse it if you retry
);

console.log(payment.id, payment.status); // …  CREATED
console.log(payment.checkout_url);
Python
# create_payment.py
import time, uuid
from htp import htp

order_no = f"A{int(time.time() * 1000)}"
payment = htp(
    "POST",
    "/open/v1/payments",
    {"out_order_no": order_no, "amount": "1.00", "currency": "USDT", "description": "Test order"},
    uuid.uuid4().hex,  # Idempotency-Key — reuse it if you retry
)

print(payment["id"], payment["status"])  # …  CREATED
print(payment["checkout_url"])
Go
// main.go
package main

import (
	"crypto/rand"
	"encoding/hex"
	"fmt"
	"log"
	"time"
)

func main() {
	idem := make([]byte, 16)
	rand.Read(idem) // Idempotency-Key — reuse it if you retry

	var payment struct {
		ID          string `json:"id"`
		Status      string `json:"status"`
		CheckoutURL string `json:"checkout_url"`
	}
	err := htp("POST", "/open/v1/payments", map[string]string{
		"out_order_no": fmt.Sprintf("A%d", time.Now().UnixMilli()),
		"amount":       "1.00",
		"currency":     "USDT",
		"description":  "Test order",
	}, hex.EncodeToString(idem), &payment)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(payment.ID, payment.Status) // …  CREATED
	fmt.Println(payment.CheckoutURL)
}
Java
// CreatePayment.java
import java.util.UUID;

public class CreatePayment {
    public static void main(String[] args) throws Exception {
        String orderNo = "A" + System.currentTimeMillis();
        String body = "{\"out_order_no\":\"" + orderNo + "\",\"amount\":\"1.00\",\"currency\":\"USDT\",\"description\":\"Test order\"}";
        String idem = UUID.randomUUID().toString().replace("-", ""); // Idempotency-Key — reuse it if you retry

        String json = Htp.call("POST", "/open/v1/payments", body, idem);
        System.out.println(json); // data.id, data.status = CREATED, data.checkout_url
    }
}
PHP
<?php
// create-payment.php
require __DIR__ . '/htp.php';

$payment = htp(
    'POST',
    '/open/v1/payments',
    ['out_order_no' => 'A' . floor(microtime(true) * 1000), 'amount' => '1.00', 'currency' => 'USDT', 'description' => 'Test order'],
    bin2hex(random_bytes(16)), // Idempotency-Key — reuse it if you retry
);

echo $payment['id'], ' ', $payment['status'], PHP_EOL; // …  CREATED
echo $payment['checkout_url'], PHP_EOL;

用测试用户付款

用 App 的测试用户(在控制台测试用户中添加)登录 UC Card App,打开 checkout_url,输入支付 PIN 确认。

请使用测试用户,而不是你自己的账号

开发者永远不能向自己的 App 付款(SELF_PAYMENT_FORBIDDEN),沙箱和生产都一样。沙箱订单只能由 App 的测试用户支付(其他人返回 SANDBOX_USER_ONLY)。

接收回调

UC Card 向你的回调地址 POST payment.paid。信任之前先校验签名 —— 代码见回调。

json
{
  "event_id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "event": "payment.paid",
  "created_at": "2026-11-07T09:05:12Z",
  "data": { "id": "0b7a9c62-…", "out_order_no": "A1730970000000", "status": "PAID", "amount": "1.00", "net_amount": "0.99", "user_sub": "2f6c0d1e8a9b4c7d", "…": "…" }
}

还没有公网地址?可以改为查询订单:

Node.js
const order = await htp('GET', `/open/v1/orders/${payment.id}`);
console.log(order.status); // PAID
Python
order = htp("GET", f"/open/v1/orders/{payment['id']}")
print(order["status"])  # PAID
Go
var order struct{ Status string `json:"status"` }
if err := htp("GET", "/open/v1/orders/"+payment.ID, nil, "", &order); err != nil {
	log.Fatal(err)
}
fmt.Println(order.Status) // PAID
Java
String order = Htp.call("GET", "/open/v1/orders/" + paymentId, null, null);
System.out.println(order); // data.status = PAID
PHP
$order = htp('GET', '/open/v1/orders/' . $payment['id']);
echo $order['status'], PHP_EOL; // PAID

完成

接下来:处理退款,在沙箱与测试中测试各种失败场景,然后对照上线清单。