签名算法
签名头格式
X-Pmp-Signature: t={timestamp},v1={hmac_hex}
X-Pmp-Signature: t=1749081600,v1=a1b2c3d4e5f6...
签名串构造
payload = timestamp + "." + rawBody
timestamp:Unix 秒级时间戳,来自X-Pmp-Timestamp头rawBody:HTTP 请求体的原始字符串(未解析的 JSON)
签名计算
hmac_hex = HMAC-SHA256(secret, payload)
secret 为商户在 Webhook 配置中设置的签名密钥。
验签步骤
收到 Webhook 请求后,按以下步骤验签:1. 解析签名头
从X-Pmp-Signature 中提取 t 和 v1:
X-Pmp-Signature: t=1749081600,v1=a1b2c3d4e5f6...
提取结果:
timestamp = 1749081600
received_signature = a1b2c3d4e5f6...
2. 检查时间戳
验证t 是否在允许的时间窗口内(建议 ±5 分钟):
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) {
// 拒绝:时间戳超出允许范围
}
3. 计算本地签名
使用你的 Webhook Secret 重新计算签名:const payload = timestamp + '.' + rawBody;
const expectedSignature = crypto
.createHmac('sha256', webhookSecret)
.update(payload)
.digest('hex');
4. 常量时间比较
使用常量时间比较防止时序攻击:// 不要用 === 直接比较
crypto.timingSafeEqual(
Buffer.from(received_signature),
Buffer.from(expectedSignature)
);
5. 幂等处理
检查event_id 是否已处理过,避免重复处理同一事件。
验签示例代码
- Node.js
- Java
- Python
const crypto = require('crypto');
function verifyWebhook(headers, rawBody, secret) {
// 1. 解析签名头
const sigHeader = headers['x-pmp-signature'];
const match = sigHeader.match(/t=(\d+),v1=([a-f0-9]+)/);
if (!match) {
return false; // 格式错误
}
const timestamp = match[1];
const receivedSig = match[2];
// 2. 检查时间窗口
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
return false; // 时间戳过期
}
// 3. 计算本地签名
const payload = timestamp + '.' + rawBody;
const expectedSig = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
// 4. 常量时间比较
try {
return crypto.timingSafeEqual(
Buffer.from(receivedSig),
Buffer.from(expectedSig)
);
} catch {
return false;
}
}
// 使用示例
app.post('/webhook', (req, res) => {
const rawBody = JSON.stringify(req.body); // 注意:需获取原始 body
const isValid = verifyWebhook(req.headers, rawBody, WEBHOOK_SECRET);
if (!isValid) {
return res.status(401).send('Invalid signature');
}
const eventId = req.body.event_id;
// 检查 eventId 是否已处理...
// 处理业务逻辑...
res.status(200).send('OK');
});
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
public class WebhookVerifier {
public static boolean verify(String signatureHeader, String rawBody,
String secret) {
// 1. 解析签名头
String[] parts = signatureHeader.split(",");
String t = parts[0].substring(2); // "t=123" -> "123"
String v1 = parts[1].substring(3); // "v1=abc" -> "abc"
// 2. 检查时间窗口
long timestamp = Long.parseLong(t);
long now = System.currentTimeMillis() / 1000;
if (Math.abs(now - timestamp) > 300) {
return false;
}
// 3. 计算本地签名
String payload = t + "." + rawBody;
String expected = hmacSha256Hex(secret, payload);
// 4. 常量时间比较
return MessageDigest.isEqual(
expected.getBytes(), v1.getBytes()
);
}
private static String hmacSha256Hex(String secret, String data) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec keySpec = new SecretKeySpec(
secret.getBytes("UTF-8"), "HmacSHA256");
mac.init(keySpec);
byte[] result = mac.doFinal(data.getBytes("UTF-8"));
StringBuilder sb = new StringBuilder();
for (byte b : result) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
import hmac
import hashlib
import time
def verify_webhook(signature_header: str, raw_body: str, secret: str) -> bool:
# 1. 解析签名头
# "t=123,v1=abc" -> t="123", v1="abc"
parts = dict(p.split("=") for p in signature_header.split(","))
timestamp = parts["t"]
received_sig = parts["v1"]
# 2. 检查时间窗口(±5分钟)
now = int(time.time())
if abs(now - int(timestamp)) > 300:
return False
# 3. 计算本地签名
payload = f"{timestamp}.{raw_body}"
expected_sig = hmac.new(
secret.encode("utf-8"),
payload.encode("utf-8"),
hashlib.sha256
).hexdigest()
# 4. 常量时间比较
return hmac.compare_digest(received_sig, expected_sig)
常见问题
验签失败?
- 确认使用的是原始请求体字符串(不是 JSON 解析后重新序列化的)
- 确认 Secret 与商户门户配置的一致
- 确认
X-Pmp-Signature头格式:t=xxx,v1=xxx - 确认签名串为
timestamp.rawBody,中间是点号.
如何处理重复通知?
平台可能因网络重试等原因重复推送同一事件。务必依据event_id 做幂等处理:
// 伪代码
if (processedEventIds.has(eventId)) {
return res.status(200).send('Already processed');
}
processEvent(event);
processedEventIds.add(eventId);
res.status(200).send('OK');
