Skip to content

Webhook 回调验签 ​

ADOPAY 向商户回调业务结果前,会使用平台私钥对 HTTP 请求加签。商户必须使用开户时取得的平台公钥完成验签,验签通过后才能持久化或处理业务数据。

本页描述当前平台服务端实际使用的回调签名协议。事件字段、重试和幂等要求见 Webhook 规范。商户调用 ADOPAY 接口时使用的请求签名见 请求签名。

1. 完整链路 ​

mermaid
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 格式如下:

text
Signature keyId="<签名主体标识>",alg="ES256",headers="(request-target) x-timestamp x-nonce digest",signature="<标准Base64签名>"

两个 keyId 含义不同

Authorization 参数中的 keyId 是签名主体标识;当前回调链路未显式指定时,它会使用接收通知的商户号。独立请求头 Authorization.keyId 才是当前的平台签名公钥版本(当前为 v1)。商户应按平台提供的版本信息选择平台公钥,不要要求这两个值相等。

3. 验签主流程 ​

按下面的顺序处理每个回调:

  1. 保留原始数据:在 JSON 解析之前保存原始 body 字节,同时保留请求的转义路径和原始 query 字符串。
  2. 检查必填请求头:缺少第 2 节任一必填头时直接拒绝。
  3. 校验时间戳:将 X-Timestamp 按 Unix 秒解析,建议仅接受与当前时间相差不超过 300 秒的请求。
  4. 校验 Digest:计算 Base64(SHA-256(原始 body 字节)),加上 SHA-256= 前缀后,与 Digest 逐字节比较。
  5. 校验签名元数据:解析 Authorization,确认 alg 严格等于 ES256,headers 严格等于 (request-target) x-timestamp x-nonce digest。
  6. 选择平台公钥:优先按平台提供的签名密钥版本选择对应环境的平台公钥。Sandbox 与生产环境的公钥相互独立。
  7. 重建签名串:按第 4 节固定顺序构造四行文本,行之间只使用 \n,最后一行末尾不加换行。
  8. 执行 ES256 验签:对签名串做 SHA-256,使用 P-256 平台公钥验证 ASN.1/DER 格式的 ECDSA 签名;signature 使用标准 Base64 解码,不是 Base64URL。
  9. 防重放:以 X-Merchant-Id + X-Nonce 为键做原子去重,保存时间至少覆盖允许的时间戳窗口。
  10. 处理业务:只有以上步骤全部通过后,才进行事件幂等检查和持久化。

任一步失败都不得处理业务数据,也不得降级成“只记录日志后继续处理”。

4. 重建签名串 ​

签名串固定为四行:

text
(request-target): <小写HTTP方法> <转义后的路径>[?<原始query>]
x-timestamp: <X-Timestamp原值>
x-nonce: <X-Nonce原值>
digest: <Digest原值>

例如,平台向以下地址发起回调:

text
POST https://merchant.example.com/webhooks/payment?source=adopay

收到的请求头为:

http
X-Timestamp: 1790200000
X-Nonce: 550e8400-e29b-41d4-a716-446655440000
Digest: SHA-256=hxLlwyZ8YdkeKVEmKUMSxnDkPaDnj9LAp8+jwyqwRaI=

则验签串为:

text
(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 计算公式:

text
Digest = "SHA-256=" + Base64Standard(SHA256(rawBodyBytes))

签名验证公式:

text
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
// 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
// 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
# 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
javascript
// 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. 验签后的响应 ​

当前平台只有同时满足以下两个条件才把本次投递认定为成功:

  1. HTTP 状态码为 200 到 299;
  2. 响应体去除首尾空白后,严格等于 SUCCESS 或 OK(区分大小写)。

推荐响应:

http
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。