签名与鉴权

v2 API 每个请求都必须携带以下头并通过 HMAC-SHA256 验签:

Header说明
X-API-KeyKey ID(llp_test_* / llp_live_*)
X-TimestampUnix 秒。与服务器时间误差超过 ±300 秒拒绝
X-Nonce8-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 禁止出现在浏览器、移动端或版本库中