开发者/快速开始
认证与签名
每个 API 请求都要带上你的 Key ID 和签名。签名证明请求确实来自你,并且请求内容不能被篡改,也不能被重复利用。签名密钥本身永远不会被发送。
#凭证
| 凭证 | 示例 | 是否保密 |
|---|---|---|
| Key ID | ck_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 请求也不例外:
X-Key-Id: ck_live_xxxxxxxxxxxxxxxxxxxxxxxx
X-Timestamp: 1790000000
X-Nonce: 0123456789abcdef0123456789abcdef
X-Signature: e6e45d09ed271d8d721701b560d56a6f5104fd8a52aaad5380166f432bba6e89X-Key-Id:你的 Key 的公开 ID。X-Timestamp:当前 Unix 时间(秒)。和 CardV 服务器时间相差不能超过 5 分钟(300 秒)。X-Nonce:随机串,每次都不能重复。建议使用 32 位随机十六进制字符。X-Signature:签名,计算方法见请求签名,必须是小写十六进制。
你也可以带上 X-Merchant-Id。如果带了,它必须和 Key 对应的商户号一致。
#请求签名
把请求体转换成字节,只转换一次。签名和发送都用这同一份字节。
GET请求的请求体为空。计算请求体哈希:
BODY_HASH= 请求体的 SHA-256,小写十六进制。请求体为空时,结果是e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855。把下面五行用换行符(
\n)连接起来,末尾不要加换行:<METHOD> <PATH with query string> <X-Timestamp> <X-Nonce> <BODY_HASH>METHOD要大写,例如GET或POST。路径要包含查询字符串,并且和实际发送的完全一致,例如/api/v1/skus?limit=50。计算签名:
X-Signature= 用你的签名密钥作为密钥,对上面这段文本做 HMAC-SHA256。结果写成小写十六进制。
话费充值的请求也用同样的方法签名,但要用各自的路径,例如 /api/v1/recharge/orders。
最常见的错误是:签名用的 JSON 和实际发送的 JSON 不是同一份。比如 {"a":1} 和 {"a": 1} 的字节就不一样。请确认你的 HTTP 库在签名之后没有改动请求体,也没有重新排列查询字符串。
测试数据:用下面的数据验证你的代码(密钥是假的)。
Signing secret cs_test_TEST_ONLY_not_a_real_secret_0123456789abcdef
X-Timestamp 1790000000
X-Nonce 0123456789abcdef0123456789abcdefGET 请求:
Method GET
Path /api/v1/skus?limit=50
BODY_HASH e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
X-Signature 58f0362c355a6624fff9f0b84d47591c570263908832294375b7bf27731628e5向 /api/v1/orders 发送 POST 请求,请求体如下(106 字节,单行,末尾无换行):
{"external_order_id":"TEST-0001","items":[{"sku_id":"S000001","quantity":1,"expected_unit_price":"9.25"}]}BODY_HASH 5814ffcdadfa9eb69348150f9fd6895c00b4c37cf6cd0202b83174354645023e
X-Signature e6e45d09ed271d8d721701b560d56a6f5104fd8a52aaad5380166f432bba6e89#示例代码
以下示例都能算出上面测试数据的结果。签名密钥请从密钥管理系统读取,不要写在代码里。
cURL(bash + OpenSSL)
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)
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)
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
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。响应格式如下:
{"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。正式环境和沙盒需要分别操作。
- 创建:新建一个 Key 并命名。后台会显示 Key ID 和签名密钥,请立即把签名密钥复制到你的密钥管理系统。它只会显示这一次。
- 重新生成签名密钥:如果丢失了签名密钥,可以重新生成,Key ID 保持不变。旧签名密钥会立即失效,请马上把新签名密钥部署到你所有的服务器。
- 停用:不再需要的 Key 请及时停用,立即生效且不可恢复。停用后可以把该 Key 从列表中移除,CardV 会保留它的历史记录。
在不停机的情况下更换 Key:
- 创建一个新 Key,并保存它的签名密钥。
- 把新 Key 部署到你所有的服务器。
- 在商户后台确认旧 Key 已经没有调用。
- 停用旧 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。