Webhook 回调验签
ADOPAY 向商户回调业务结果前,会使用平台私钥对 HTTP 请求加签。商户必须使用开户时取得的平台公钥完成验签,验签通过后才能持久化或处理业务数据。
本页描述当前平台服务端实际使用的回调签名协议。事件字段、重试和幂等要求见 Webhook 规范。商户调用 ADOPAY 接口时使用的请求签名见 请求签名。
1. 完整链路
flowchart LR
A[业务状态变化] --> B[平台生成回调 JSON]
B --> C[计算原始 body 的 Digest]
C --> D[构造四行签名串]
D --> E[平台私钥生成 ES256 签名]
E --> F[携带签名请求头发送 Webhook]
F --> G[商户用平台公钥验签]
G --> H[验签通过后处理业务并返回 SUCCESS]平台加签时使用最终实际发送的 URL 和 body。商户验签时必须从收到的原始 HTTP 请求中取得相同数据,不能先解析 JSON 再重新序列化。
2. 回调请求头
| 请求头 | 必填 | 说明 |
|---|---|---|
X-Merchant-Id | 是 | 接收本次通知的商户号。 |
X-Timestamp | 是 | 平台生成签名时的 Unix 秒级时间戳。 |
X-Nonce | 是 | 本次通知的随机串,用于防重放。 |
Digest | 是 | 原始请求体摘要,格式为 SHA-256=<标准 Base64>。 |
Authorization | 是 | ES256 签名信息,包含 keyId、alg、headers 和 signature。 |
Authorization.keyId | 是 | 当前平台签名公钥版本,当前值为 v1。 |
X-Secret-Key-Version | 否 | 兼容的密钥版本头;平台取得该值时才会发送。 |
X-Trace-Id | 否 | 平台链路 ID,排查问题时请一并提供。 |
当前回调的 Authorization 格式如下:
Signature keyId="<签名主体标识>",alg="ES256",headers="(request-target) x-timestamp x-nonce digest",signature="<标准Base64签名>"两个 keyId 含义不同
Authorization 参数中的 keyId 是签名主体标识;当前回调链路未显式指定时,它会使用接收通知的商户号。独立请求头 Authorization.keyId 才是当前的平台签名公钥版本(当前为 v1)。商户应按平台提供的版本信息选择平台公钥,不要要求这两个值相等。
3. 验签主流程
按下面的顺序处理每个回调:
- 保留原始数据:在 JSON 解析之前保存原始 body 字节,同时保留请求的转义路径和原始 query 字符串。
- 检查必填请求头:缺少第 2 节任一必填头时直接拒绝。
- 校验时间戳:将
X-Timestamp按 Unix 秒解析,建议仅接受与当前时间相差不超过 300 秒的请求。 - 校验 Digest:计算
Base64(SHA-256(原始 body 字节)),加上SHA-256=前缀后,与Digest逐字节比较。 - 校验签名元数据:解析
Authorization,确认alg严格等于ES256,headers严格等于(request-target) x-timestamp x-nonce digest。 - 选择平台公钥:优先按平台提供的签名密钥版本选择对应环境的平台公钥。Sandbox 与生产环境的公钥相互独立。
- 重建签名串:按第 4 节固定顺序构造四行文本,行之间只使用
\n,最后一行末尾不加换行。 - 执行 ES256 验签:对签名串做 SHA-256,使用 P-256 平台公钥验证 ASN.1/DER 格式的 ECDSA 签名;
signature使用标准 Base64 解码,不是 Base64URL。 - 防重放:以
X-Merchant-Id + X-Nonce为键做原子去重,保存时间至少覆盖允许的时间戳窗口。 - 处理业务:只有以上步骤全部通过后,才进行事件幂等检查和持久化。
任一步失败都不得处理业务数据,也不得降级成“只记录日志后继续处理”。
4. 重建签名串
签名串固定为四行:
(request-target): <小写HTTP方法> <转义后的路径>[?<原始query>]
x-timestamp: <X-Timestamp原值>
x-nonce: <X-Nonce原值>
digest: <Digest原值>例如,平台向以下地址发起回调:
POST https://merchant.example.com/webhooks/payment?source=adopay收到的请求头为:
X-Timestamp: 1790200000
X-Nonce: 550e8400-e29b-41d4-a716-446655440000
Digest: SHA-256=hxLlwyZ8YdkeKVEmKUMSxnDkPaDnj9LAp8+jwyqwRaI=则验签串为:
(request-target): post /webhooks/payment?source=adopay
x-timestamp: 1790200000
x-nonce: 550e8400-e29b-41d4-a716-446655440000
digest: SHA-256=hxLlwyZ8YdkeKVEmKUMSxnDkPaDnj9LAp8+jwyqwRaI=构造时注意:
- 不包含 URL 的 scheme、host 或 fragment。
- path 使用请求中实际出现的转义路径;空 path 按
/处理。 - query 使用原始顺序和原始编码,不排序、不解码后重编码。
- HTTP 方法必须转成小写,当前 Webhook 为
post。 - Header 名在签名串中固定为小写,但值必须使用收到的原值。
5. Digest 与签名算法
Digest 计算公式:
Digest = "SHA-256=" + Base64Standard(SHA256(rawBodyBytes))签名验证公式:
signatureDER = Base64StandardDecode(Authorization.signature)
hash = SHA256(UTF8(canonicalString))
valid = ECDSA-P256-VerifyASN1(platformPublicKey, hash, signatureDER)平台签名是 ASN.1/DER 编码的 ECDSA 签名,不是固定 64 字节的 r || s 拼接值。
6. 多语言验签示例
以下示例都接收原始 body 字节、转义后的 path 和原始 query,并完成时间戳、Digest、签名元数据及 ES256 签名验证。示例中的 platformPublicKeyPEM 必须是按 Authorization.keyId 选出的当前环境平台公钥。
验签函数成功返回后,还必须用 Redis 或数据库以 X-Merchant-Id + X-Nonce 为键执行带 TTL 的原子 SET IF ABSENT;示例不使用进程内 Map,避免多实例部署时防重放失效。
// Go 1.20+,仅使用标准库。
package webhook
import (
"crypto/ecdsa"
"crypto/sha256"
"crypto/subtle"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
)
var authorizationPattern = regexp.MustCompile(
`^Signature keyId="([^"]+)",alg="([^"]+)",headers="([^"]+)",signature="([^"]+)"$`,
)
func VerifyWebhook(method, path, rawQuery string, headers http.Header,
body, platformPublicKeyPEM []byte, now time.Time) error {
merchantID := headers.Get("X-Merchant-Id")
timestamp := headers.Get("X-Timestamp")
nonce := headers.Get("X-Nonce")
digest := headers.Get("Digest")
authorization := headers.Get("Authorization")
keyVersion := headers.Get("Authorization.keyId")
if merchantID == "" || timestamp == "" || nonce == "" || digest == "" ||
authorization == "" || keyVersion == "" {
return fmt.Errorf("missing required signature header")
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return fmt.Errorf("invalid timestamp: %w", err)
}
diff := now.Unix() - ts
if diff < 0 {
diff = -diff
}
if diff > 300 {
return fmt.Errorf("timestamp outside allowed skew")
}
sum := sha256.Sum256(body)
expectedDigest := "SHA-256=" + base64.StdEncoding.EncodeToString(sum[:])
if subtle.ConstantTimeCompare([]byte(expectedDigest), []byte(digest)) != 1 {
return fmt.Errorf("digest mismatch")
}
parts := authorizationPattern.FindStringSubmatch(authorization)
if len(parts) != 5 || parts[2] != "ES256" ||
parts[3] != "(request-target) x-timestamp x-nonce digest" {
return fmt.Errorf("invalid Authorization metadata")
}
signatureDER, err := base64.StdEncoding.DecodeString(parts[4])
if err != nil {
return fmt.Errorf("invalid signature Base64: %w", err)
}
requestTarget := strings.ToLower(method) + " " + path
if rawQuery != "" {
requestTarget += "?" + strings.TrimPrefix(rawQuery, "?")
}
canonical := strings.Join([]string{
"(request-target): " + requestTarget,
"x-timestamp: " + timestamp,
"x-nonce: " + nonce,
"digest: " + digest,
}, "\n")
block, _ := pem.Decode(platformPublicKeyPEM)
if block == nil {
return fmt.Errorf("invalid platform public key PEM")
}
parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return err
}
publicKey, ok := parsed.(*ecdsa.PublicKey)
if !ok || publicKey.Curve.Params().Name != "P-256" {
return fmt.Errorf("platform public key must be P-256")
}
hash := sha256.Sum256([]byte(canonical))
if !ecdsa.VerifyASN1(publicKey, hash[:], signatureDER) {
return fmt.Errorf("signature invalid")
}
// 下一步:原子写入 merchantID + ":" + nonce,TTL 至少 300 秒。
return nil
}// Java 8+,仅使用标准库;平台公钥为 X.509 PUBLIC KEY PEM。
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.Signature;
import java.security.interfaces.ECPublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.time.Instant;
import java.util.Base64;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class WebhookVerifier {
private static final Pattern AUTH = Pattern.compile(
"^Signature keyId=\"([^\"]+)\",alg=\"([^\"]+)\",headers=\"([^\"]+)\",signature=\"([^\"]+)\"$");
public static void verify(String method, String path, String rawQuery,
Map<String, String> headers, byte[] rawBody,
byte[] platformPublicKeyPem, Instant now) throws Exception {
String merchantId = required(headers, "X-Merchant-Id");
String timestamp = required(headers, "X-Timestamp");
String nonce = required(headers, "X-Nonce");
String digest = required(headers, "Digest");
String authorization = required(headers, "Authorization");
required(headers, "Authorization.keyId");
long timestampSeconds = Long.parseLong(timestamp);
if (Math.abs(now.getEpochSecond() - timestampSeconds) > 300) {
throw new SecurityException("timestamp outside allowed skew");
}
String expectedDigest = "SHA-256=" + Base64.getEncoder().encodeToString(
MessageDigest.getInstance("SHA-256").digest(rawBody));
if (!MessageDigest.isEqual(expectedDigest.getBytes(StandardCharsets.US_ASCII),
digest.getBytes(StandardCharsets.US_ASCII))) {
throw new SecurityException("digest mismatch");
}
Matcher matcher = AUTH.matcher(authorization);
if (!matcher.matches() || !"ES256".equals(matcher.group(2))
|| !"(request-target) x-timestamp x-nonce digest".equals(matcher.group(3))) {
throw new SecurityException("invalid Authorization metadata");
}
String requestTarget = method.toLowerCase() + " " + path;
if (rawQuery != null && !rawQuery.isEmpty()) {
requestTarget += "?" + (rawQuery.startsWith("?") ? rawQuery.substring(1) : rawQuery);
}
String canonical = String.join("\n",
"(request-target): " + requestTarget,
"x-timestamp: " + timestamp,
"x-nonce: " + nonce,
"digest: " + digest);
String pem = new String(platformPublicKeyPem, StandardCharsets.US_ASCII)
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replaceAll("\\s", "");
ECPublicKey publicKey = (ECPublicKey) KeyFactory.getInstance("EC")
.generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(pem)));
if (publicKey.getParams().getCurve().getField().getFieldSize() != 256) {
throw new SecurityException("platform public key must be P-256");
}
Signature verifier = Signature.getInstance("SHA256withECDSA");
verifier.initVerify(publicKey);
verifier.update(canonical.getBytes(StandardCharsets.UTF_8));
if (!verifier.verify(Base64.getDecoder().decode(matcher.group(4)))) {
throw new SecurityException("signature invalid");
}
// 下一步:原子写入 merchantId + ":" + nonce,TTL 至少 300 秒。
}
private static String required(Map<String, String> headers, String name) {
return headers.entrySet().stream()
.filter(e -> e.getKey().equalsIgnoreCase(name))
.map(Map.Entry::getValue)
.filter(v -> v != null && !v.isEmpty())
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("missing header: " + name));
}
}# Python 3.9+;安装依赖:pip install cryptography
import base64
import hashlib
import hmac
import re
import time
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
AUTH = re.compile(
r'^Signature keyId="([^"]+)",alg="([^"]+)",'
r'headers="([^"]+)",signature="([^"]+)"$'
)
def verify_webhook(method, path, raw_query, headers, raw_body,
platform_public_key_pem, now=None):
headers = {key.lower(): value.strip() for key, value in headers.items()}
def required(name):
value = headers.get(name.lower(), "")
if not value:
raise ValueError(f"missing header: {name}")
return value
merchant_id = required("X-Merchant-Id")
timestamp = required("X-Timestamp")
nonce = required("X-Nonce")
digest = required("Digest")
authorization = required("Authorization")
required("Authorization.keyId")
now = int(time.time()) if now is None else int(now)
if abs(now - int(timestamp)) > 300:
raise ValueError("timestamp outside allowed skew")
expected_digest = "SHA-256=" + base64.b64encode(
hashlib.sha256(raw_body).digest()
).decode("ascii")
if not hmac.compare_digest(expected_digest, digest):
raise ValueError("digest mismatch")
match = AUTH.fullmatch(authorization)
if not match or match.group(2) != "ES256" or match.group(3) != \
"(request-target) x-timestamp x-nonce digest":
raise ValueError("invalid Authorization metadata")
request_target = f"{method.lower()} {path}"
if raw_query:
request_target += "?" + raw_query.removeprefix("?")
canonical = "\n".join([
f"(request-target): {request_target}",
f"x-timestamp: {timestamp}",
f"x-nonce: {nonce}",
f"digest: {digest}",
])
public_key = serialization.load_pem_public_key(platform_public_key_pem)
if not isinstance(public_key, ec.EllipticCurvePublicKey) or \
not isinstance(public_key.curve, ec.SECP256R1):
raise ValueError("platform public key must be P-256")
try:
public_key.verify(
base64.b64decode(match.group(4), validate=True),
canonical.encode("utf-8"),
ec.ECDSA(hashes.SHA256()),
)
except InvalidSignature as exc:
raise ValueError("signature invalid") from exc
# 下一步:原子写入 merchant_id + ":" + nonce,TTL 至少 300 秒。
return merchant_id, nonce// Node.js 18+,仅使用内置 node:crypto。
const {
createHash,
createPublicKey,
timingSafeEqual,
verify
} = require('node:crypto')
const AUTH = /^Signature keyId="([^"]+)",alg="([^"]+)",headers="([^"]+)",signature="([^"]+)"$/
function verifyWebhook({ method, path, rawQuery, headers, rawBody,
platformPublicKeyPem, nowSeconds = Math.floor(Date.now() / 1000) }) {
const normalized = Object.fromEntries(
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), String(value).trim()])
)
const required = (name) => {
const value = normalized[name.toLowerCase()]
if (!value) throw new Error(`missing header: ${name}`)
return value
}
const merchantId = required('X-Merchant-Id')
const timestamp = required('X-Timestamp')
const nonce = required('X-Nonce')
const digest = required('Digest')
const authorization = required('Authorization')
required('Authorization.keyId')
if (!/^\d+$/.test(timestamp) || Math.abs(nowSeconds - Number(timestamp)) > 300) {
throw new Error('timestamp outside allowed skew')
}
const expectedDigest = 'SHA-256=' + createHash('sha256').update(rawBody).digest('base64')
const expectedBytes = Buffer.from(expectedDigest, 'ascii')
const actualBytes = Buffer.from(digest, 'ascii')
if (expectedBytes.length !== actualBytes.length ||
!timingSafeEqual(expectedBytes, actualBytes)) {
throw new Error('digest mismatch')
}
const match = AUTH.exec(authorization)
if (!match || match[2] !== 'ES256' ||
match[3] !== '(request-target) x-timestamp x-nonce digest') {
throw new Error('invalid Authorization metadata')
}
const requestTarget = method.toLowerCase() + ' ' + path +
(rawQuery ? '?' + rawQuery.replace(/^\?/, '') : '')
const canonical = [
`(request-target): ${requestTarget}`,
`x-timestamp: ${timestamp}`,
`x-nonce: ${nonce}`,
`digest: ${digest}`
].join('\n')
const publicKey = createPublicKey(platformPublicKeyPem)
if (publicKey.asymmetricKeyType !== 'ec' ||
publicKey.asymmetricKeyDetails?.namedCurve !== 'prime256v1') {
throw new Error('platform public key must be P-256')
}
const signature = Buffer.from(match[4], 'base64')
if (!verify('sha256', Buffer.from(canonical, 'utf8'), publicKey, signature)) {
throw new Error('signature invalid')
}
// 下一步:原子写入 merchantId + ':' + nonce,TTL 至少 300 秒。
return { merchantId, nonce }
}框架接入时,path 应取未经解码的请求路径,rawQuery 不包含开头的 ?。例如 Go 使用 r.URL.EscapedPath() 与 r.URL.RawQuery;Node.js 应从原始请求 URL 中截取 query,而不是重新序列化查询参数对象。
7. 验签后的响应
当前平台只有同时满足以下两个条件才把本次投递认定为成功:
- HTTP 状态码为
200到299; - 响应体去除首尾空白后,严格等于
SUCCESS或OK(区分大小写)。
推荐响应:
HTTP/1.1 200 OK
Content-Type: text/plain
SUCCESS应先完成验签、Nonce 防重放、事件幂等检查和可靠持久化,再立即返回成功;耗时业务逻辑放到异步任务中执行。
8. 常见验签失败原因
- 使用解析并重新序列化后的 JSON 计算 Digest,导致空格、字段顺序或转义发生变化。
- 使用解码后的 path,或重新排序、重新编码 query。
- 将毫秒时间戳当成秒级时间戳。
- 在四行签名串末尾多加了一个换行符。
- 使用 Base64URL 解码
signature,或把 DER 签名当成r || s。 - 使用商户自己的公钥验签;回调必须使用 ADOPAY 提供的平台公钥。
- 将
Authorization内的keyId与独立的Authorization.keyId请求头误认为同一字段。 - 代理、网关或 Web 框架在应用读取前改写了 path、query 或 body。