开发者/快速开始

认证与签名

每个 API 请求都要带上你的 Key ID 和签名。签名证明请求确实来自你,并且请求内容不能被篡改,也不能被重复利用。签名密钥本身永远不会被发送。

相关文档:商品与下单 · 安全 · 概览

#凭证

凭证示例是否保密
Key IDck_live_...否,它只表明你用的是哪个 Key。
签名密钥(Signing secret)cs_live_...是,你用它来签名,它永远不能离开你的服务器。
商户号(Merchant ID)M00000001否,API 请求中可以不带。
  • API Key 由 Owner 在商户后台创建(见 API Key 管理)。
  • 签名密钥只在创建 Key 时显示一次。之后任何人都无法再次查看,包括 CardV。
  • 正式环境的 Key 以 ck_live_ 和 cs_live_ 开头,沙盒的 Key 以 ck_test_ 和 cs_test_ 开头。一个环境的 Key 在另一个环境里永远不能用。
  • 只有在 CardV 审核通过你的企业资料后,API 才能使用。此时 GET/account 会返回 "api_access_enabled": true。

#请求头

每个请求都要带这四个请求头,GET 请求也不例外:

HTTP
X-Key-Id: ck_live_xxxxxxxxxxxxxxxxxxxxxxxx
X-Timestamp: 1790000000
X-Nonce: 0123456789abcdef0123456789abcdef
X-Signature: e6e45d09ed271d8d721701b560d56a6f5104fd8a52aaad5380166f432bba6e89
  • X-Key-Id:你的 Key 的公开 ID。
  • X-Timestamp:当前 Unix 时间(秒)。和 CardV 服务器时间相差不能超过 5 分钟(300 秒)。
  • X-Nonce:随机串,每次都不能重复。建议使用 32 位随机十六进制字符。
  • X-Signature:签名,计算方法见请求签名,必须是小写十六进制。

你也可以带上 X-Merchant-Id。如果带了,它必须和 Key 对应的商户号一致。

#请求签名

  1. 把请求体转换成字节,只转换一次。签名和发送都用这同一份字节。 GET 请求的请求体为空。

  2. 计算请求体哈希:BODY_HASH = 请求体的 SHA-256,小写十六进制。请求体为空时,结果是 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855。

  3. 把下面五行用换行符(\n)连接起来,末尾不要加换行:

    Text
    <METHOD>
    <PATH with query string>
    <X-Timestamp>
    <X-Nonce>
    <BODY_HASH>

    METHOD 要大写,例如 GET 或 POST。路径要包含查询字符串,并且和实际发送的完全一致,例如 /api/v1/skus?limit=50。

  4. 计算签名:X-Signature = 用你的签名密钥作为密钥,对上面这段文本做 HMAC-SHA256。结果写成小写十六进制。

话费充值的请求也用同样的方法签名,但要用各自的路径,例如 /api/v1/recharge/orders。

最常见的错误是:签名用的 JSON 和实际发送的 JSON 不是同一份。比如 {"a":1} 和 {"a": 1} 的字节就不一样。请确认你的 HTTP 库在签名之后没有改动请求体,也没有重新排列查询字符串。

测试数据:用下面的数据验证你的代码(密钥是假的)。

Text
Signing secret  cs_test_TEST_ONLY_not_a_real_secret_0123456789abcdef
X-Timestamp     1790000000
X-Nonce         0123456789abcdef0123456789abcdef

GET 请求:

Text
Method       GET
Path         /api/v1/skus?limit=50
BODY_HASH    e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
X-Signature  58f0362c355a6624fff9f0b84d47591c570263908832294375b7bf27731628e5

向 /api/v1/orders 发送 POST 请求,请求体如下(106 字节,单行,末尾无换行):

JSON
{"external_order_id":"TEST-0001","items":[{"sku_id":"S000001","quantity":1,"expected_unit_price":"9.25"}]}
Text
BODY_HASH    5814ffcdadfa9eb69348150f9fd6895c00b4c37cf6cd0202b83174354645023e
X-Signature  e6e45d09ed271d8d721701b560d56a6f5104fd8a52aaad5380166f432bba6e89

#示例代码

以下示例都能算出上面测试数据的结果。签名密钥请从密钥管理系统读取,不要写在代码里。

cURL(bash + OpenSSL)

Shell
BASE_URL="https://sandbox.cardv.net"
REQ_PATH="/api/v1/orders"      # do not call this variable PATH
BODY='{"external_order_id":"SHOP-10001","items":'
BODY+='[{"sku_id":"S000001","quantity":1,"expected_unit_price":"9.25"}]}'
TS=$(date +%s)
NONCE=$(openssl rand -hex 16)
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | sed 's/^.*= //')
SIG=$(printf 'POST\n%s\n%s\n%s\n%s' "$REQ_PATH" "$TS" "$NONCE" "$BODY_HASH" \
  | openssl dgst -sha256 -hmac "$CARDV_SIGNING_SECRET" -hex | sed 's/^.*= //')

curl -sS -X POST "$BASE_URL$REQ_PATH" \
  -H "Content-Type: application/json" \
  -H "X-Key-Id: $CARDV_KEY_ID" \
  -H "X-Timestamp: $TS" \
  -H "X-Nonce: $NONCE" \
  -H "X-Signature: $SIG" \
  --data-raw "$BODY"

请用 --data-raw,不要用 -d @file。-d 会去掉换行,导致发送的字节和签名时不一致。 GET 请求要对 GET、带查询字符串的路径和空请求体的哈希签名,并且不发送请求体。

Python(requests)

Python
import hashlib, hmac, json, os, secrets, time
import requests

BASE_URL = "https://sandbox.cardv.net"
KEY_ID = os.environ["CARDV_KEY_ID"]
SIGNING_SECRET = os.environ["CARDV_SIGNING_SECRET"]


def signed_headers(method: str, path: str, body: bytes) -> dict:
    ts, nonce = str(int(time.time())), secrets.token_hex(16)
    body_hash = hashlib.sha256(body).hexdigest()
    text = "\n".join([method, path, ts, nonce, body_hash])
    signature = hmac.new(SIGNING_SECRET.encode(), text.encode(), hashlib.sha256).hexdigest()
    return {"X-Key-Id": KEY_ID, "X-Timestamp": ts, "X-Nonce": nonce, "X-Signature": signature}


def cardv_get(path: str) -> requests.Response:
    # Put the query string in `path` so the signed path and the sent path are identical.
    return requests.get(BASE_URL + path, headers=signed_headers("GET", path, b""), timeout=30)


def cardv_post(path: str, payload: dict) -> requests.Response:
    body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode()
    headers = {**signed_headers("POST", path, body), "Content-Type": "application/json"}
    return requests.post(BASE_URL + path, data=body, headers=headers, timeout=30)


resp = cardv_post("/api/v1/orders", {
    "external_order_id": "SHOP-10001",
    "items": [{"sku_id": "S000001", "quantity": 1, "expected_unit_price": "9.25"}],
})
print(resp.status_code, resp.json())

order_id = resp.json()["order"]["order_id"]
print(cardv_get(f"/api/v1/orders/{order_id}").json()["status"])

Node.js 18+(内置 fetch)

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

const BASE_URL = "https://sandbox.cardv.net";
const { CARDV_KEY_ID, CARDV_SIGNING_SECRET } = process.env;

function signedHeaders(method, path, body) {
  const timestamp = String(Math.floor(Date.now() / 1000));
  const nonce = crypto.randomBytes(16).toString("hex");
  const bodyHash = crypto.createHash("sha256").update(body).digest("hex");
  const text = [method, path, timestamp, nonce, bodyHash].join("\n");
  const signature = crypto.createHmac("sha256", CARDV_SIGNING_SECRET).update(text, "utf8").digest("hex");
  return { "X-Key-Id": CARDV_KEY_ID, "X-Timestamp": timestamp, "X-Nonce": nonce, "X-Signature": signature };
}

async function cardvGet(path) {
  const res = await fetch(BASE_URL + path, { headers: signedHeaders("GET", path, Buffer.alloc(0)) });
  return { status: res.status, body: await res.json() };
}

async function cardvPost(path, payload) {
  const body = Buffer.from(JSON.stringify(payload), "utf8"); // serialize once
  const headers = { ...signedHeaders("POST", path, body), "Content-Type": "application/json" };
  const res = await fetch(BASE_URL + path, { method: "POST", headers, body });
  return { status: res.status, body: await res.json() };
}

console.log(await cardvPost("/api/v1/orders", {
  external_order_id: "SHOP-10001",
  items: [{ sku_id: "S000001", quantity: 1, expected_unit_price: "9.25" }],
}));
console.log(await cardvGet("/api/v1/balance"));

PHP(仅签名部分)

PHP
<?php
function cardv_sign(
    string $secret, string $method, string $path, string $body, string $ts, string $nonce
): string {
    $text = implode("\n", [$method, $path, $ts, $nonce, hash('sha256', $body)]);
    return hash_hmac('sha256', $text, $secret); // lowercase hex
}

// Send exactly $body. For a GET call use $body = ''.
$body = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$ts = (string) time();
$nonce = bin2hex(random_bytes(16));
$signature = cardv_sign($signingSecret, 'POST', '/api/v1/orders', $body, $ts, $nonce);
// Headers: X-Key-Id, X-Timestamp, X-Nonce, X-Signature

#错误处理

下面这些错误都返回 HTTP 403(不是 401),只有频率超限返回 429。响应格式如下:

JSON
{"detail": "Invalid HMAC signature."}
问题怎么处理
Key ID 不存在或已停用检查 X-Key-Id,以及是否用错了环境(正式环境或沙盒)。
API 尚未开通等待 CardV 审核通过你的企业资料。
你的服务器 IP 不在白名单内在商户后台把它加入 IP 白名单。
该接口只能在商户后台使用请到商户后台操作。API Key 只能调用 16 个接口。
签名缺失或错误修正签名代码,用测试数据核对。
时间戳过早或过晚校准服务器时间(NTP)。
随机串已被使用每次请求都生成新的随机串。
请求过于频繁(429)按 Retry-After 给出的秒数等待后再试。

注意:

  • 重新发送请求时,要生成新的时间戳、随机串和签名,请求体保持不变。见安全重试。
  • 即使签名错误,这个随机串也会被记为已使用。
  • CardV 会记录失败的请求,失败次数异常时会通知我们的团队。

#IP 白名单

IP 白名单可以限定只有你自己的服务器才能使用这个 Key。由 Owner 在商户后台管理。

  • 白名单是可选的。没有任何规则时,任何 IP 都可以调用。
  • 只要添加了一条规则,其他 IP 的请求都会返回 HTTP 403。
  • 规则格式如 203.0.113.10/32(IPv4)或 2001:db8::/48(IPv6)。
  • 白名单只限制 API Key 调用,不影响商户后台登录。
  • 启用前,请把所有服务器的出口 IP 都加进去,包括 NAT 网关和备用机房。

#API Key 管理

API Key 只能调用接口一览里的 16 个接口。其中包括下单和读取卡密,所以请像保护 Owner 密码一样保护签名密钥。

只有 Owner 可以管理 Key,位置在商户后台的 Integrations → API keys。正式环境和沙盒需要分别操作。

  1. 创建:新建一个 Key 并命名。后台会显示 Key ID 和签名密钥,请立即把签名密钥复制到你的密钥管理系统。它只会显示这一次。
  2. 重新生成签名密钥:如果丢失了签名密钥,可以重新生成,Key ID 保持不变。旧签名密钥会立即失效,请马上把新签名密钥部署到你所有的服务器。
  3. 停用:不再需要的 Key 请及时停用,立即生效且不可恢复。停用后可以把该 Key 从列表中移除,CardV 会保留它的历史记录。

在不停机的情况下更换 Key:

  1. 创建一个新 Key,并保存它的签名密钥。
  2. 把新 Key 部署到你所有的服务器。
  3. 在商户后台确认旧 Key 已经没有调用。
  4. 停用旧 Key。

建议至少每年更换一次 Key;接触过 Key 的人员离职时也要更换。如果怀疑 Key 已经泄露,请先停用,再排查原因。

#旧版 Key

2026 年 10 月之前创建的 Key 以 cvb2b_ 开头,使用旧的请求头 X-Merchant-Id 和 X-Api-Key,并且只有 POST 请求需要签名。旧版 Key 会随每个请求一起发送,因此更容易泄露。

正式环境(Live)和沙盒(Sandbox)都已不再接受这类 Key。请现在就迁移到新 Key:在商户后台创建新 Key,把代码改为使用上面的请求头,然后停用旧 Key。

接入遇到问题?请发送邮件至 [email protected],并附上 Merchant ID 与订单号或请求 ID。