签名与鉴权
v2 API 每个请求都必须携带以下头并通过 HMAC-SHA256 验签:
| Header | 说明 |
|---|---|
X-API-Key | Key ID(llp_test_* / llp_live_*) |
X-Timestamp | Unix 秒。与服务器时间误差超过 ±300 秒拒绝 |
X-Nonce | 8-64 位随机串。同一 Key 下一次性使用(数据库唯一约束防重放) |
X-Signature | 对 Canonical Request 的 HMAC-SHA256 十六进制签名 |
Idempotency-Key | 创建类接口必填(1-255 字符),见下文幂等语义 |
Canonical Request
六行内容以 \n 连接后计算 HMAC-SHA256(secret, canonical),输出小写 hex:
HTTP_METHOD # 大写,如 POST PATH # 不含 query,如 /api/v2/payment-intents CANONICAL_QUERY # 按 key ASCII 排序的 k=v&k2=v2(key/value 均 encodeURIComponent);无参数为空行 TIMESTAMP # 与 X-Timestamp 一致 NONCE # 与 X-Nonce 一致 SHA256_HEX(BODY) # 原始请求体的 SHA-256;GET 等无 body 时对空字符串取哈希
签名针对原始字节:序列化后的 JSON 字符串必须与实际发送的完全一致(不要发送后再次序列化)。
幂等语义
- 唯一键:
商户 + API Key + 接口 + Idempotency-Key - 同 Key 同请求体:返回首次结果,响应头带
Idempotency-Replayed: true - 同 Key 不同请求体:
409 IDEMPOTENCY_CONFLICT - 首次请求仍在处理:
409 IDEMPOTENCY_IN_PROGRESS,稍后以相同 Key 重试 - 处理失败会释放该 Key,可安全重试;记录保留 24 小时
多语言签名示例
PHP
<?php
function llpSign(string $method, string $path, string $body,
string $timestamp, string $nonce, string $secret): string {
$canonical = implode("\n", [
strtoupper($method), $path, '', $timestamp, $nonce, hash('sha256', $body),
]);
return hash_hmac('sha256', $canonical, $secret);
}
$body = json_encode([
'merchant_order_no' => 'M' . time(),
'product_code' => '你的产品编码',
'amount' => '100.00',
'subject' => 'VIP 会员',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$timestamp = (string) time();
$nonce = bin2hex(random_bytes(16));
$sign = llpSign('POST', '/api/v2/payment-intents', $body, $timestamp, $nonce, $secret);Python
import hashlib, hmac, json, time, uuid, requests
def llp_sign(method, path, body, timestamp, nonce, secret):
canonical = "\n".join([
method.upper(), path, "", timestamp, nonce,
hashlib.sha256(body.encode()).hexdigest(),
])
return hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()
body = json.dumps({
"merchant_order_no": f"M{int(time.time())}",
"product_code": "你的产品编码",
"amount": "100.00",
"subject": "VIP 会员",
}, ensure_ascii=False, separators=(",", ":"))
timestamp, nonce = str(int(time.time())), uuid.uuid4().hex
headers = {
"content-type": "application/json",
"x-api-key": KEY_ID,
"x-timestamp": timestamp,
"x-nonce": nonce,
"x-signature": llp_sign("POST", "/api/v2/payment-intents", body, timestamp, nonce, SECRET),
"idempotency-key": uuid.uuid4().hex,
}
requests.post(API + "/api/v2/payment-intents", data=body.encode(), headers=headers)Java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.HexFormat;
String canonical = String.join("\n",
"POST", "/api/v2/payment-intents", "", timestamp, nonce,
HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256").digest(body.getBytes("UTF-8"))));
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes("UTF-8"), "HmacSHA256"));
String signature = HexFormat.of().formatHex(mac.doFinal(canonical.getBytes("UTF-8")));curl(配合 shell 计算)
BODY='{"merchant_order_no":"M1","product_code":"YOUR_PRODUCT_CODE","amount":"100.00","subject":"Test"}'
TS=$(date +%s); NONCE=$(openssl rand -hex 16)
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $2}')
CANON=$(printf 'POST\n/api/v2/payment-intents\n\n%s\n%s\n%s' "$TS" "$NONCE" "$BODY_HASH")
SIGN=$(printf '%s' "$CANON" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
curl -X POST "$API/api/v2/payment-intents" \
-H "content-type: application/json" -H "x-api-key: $KEY_ID" \
-H "x-timestamp: $TS" -H "x-nonce: $NONCE" -H "x-signature: $SIGN" \
-H "idempotency-key: $(openssl rand -hex 8)" -d "$BODY"密钥安全
- Secret 仅创建时展示一次,平台以 AES-256-GCM 加密存储,任何界面只显示末 4 位
- 怀疑泄露:商户后台「API Key」立即撤销并新建;Webhook Secret 支持 rotate-secret 轮换
- Live Key 禁止出现在浏览器、移动端或版本库中