Skip to content

Webhook 回调规范

概述

支付、退款或取消完成后,Codrimpay 会向商户配置的回调地址发送 HTTP POST 通知。

回调地址优先级:

  1. 创建交易时传入的 notifyUrl
  2. 商户配置中的 webHookUrl

请求说明

  • 方法POST
  • Content-Typeapplication/json
  • 重试机制:商户未返回 HTTP 200 时,系统会自动重试

回调字段

字段名类型说明
typeString通知类型:PAY / REFUND / CANCEL
transactionOrderIdString支付订单号
refundTransactionIdString退款单号(退款场景)
relationIdString商户关联 ID
merchantIdString商户号
statusString订单状态码,见状态码说明
payAmountString支付金额(PAY)/ 退款金额(REFUND)
txnAmountString渠道侧交易金额
txnCurrencyString渠道侧币种
currencyString订单币种
responseTimeString渠道回调时间
paymentTypeString支付类型(如 CARDKLARNA
paymentString支付渠道(如 pacypaypaypal
failedMsgString失败原因描述
failedCodeString失败原因码
resultTypeInteger返回类型:1 默认,2 需返回 URL
timestampString回调时间戳(毫秒)
nonceString随机串(防重放)
signTypeString签名类型,固定 HMAC-SHA256
signString签名值(Base64URL,无 padding)

签名验证

IMPORTANT

收到回调后必须验签,防止伪造请求。

签名算法

  1. 过滤空字段(sign 字段本身不参与)
  2. 按字段名字典序升序排序
  3. 将排序后的字段序列化为紧凑 JSON 字符串
  4. 使用 HMAC-SHA256(密钥为商户 SecretId)计算签名
  5. 结果为 Base64URL 编码(无 padding)

字段参与顺序(字典序):

currency, failedCode, failedMsg, merchantId, nonce, payAmount, payment, paymentType,
refundTransactionId, relationId, responseTime, resultType, signType, status,
timestamp, transactionOrderId, txnAmount, txnCurrency, type

验签代码示例

java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;

public boolean verifyWebhookSign(Map<String, Object> payload, String secretId) throws Exception {
    // 1. 过滤空字段,排除 sign
    Map<String, Object> filtered = payload.entrySet().stream()
        .filter(e -> !e.getKey().equals("sign") && e.getValue() != null && !e.getValue().toString().isEmpty())
        .sorted(Map.Entry.comparingByKey())
        .collect(LinkedHashMap::new, (m, e) -> m.put(e.getKey(), e.getValue()), Map::putAll);

    // 2. 序列化为紧凑 JSON
    String jsonPayload = new ObjectMapper().writeValueAsString(filtered);

    // 3. HMAC-SHA256
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secretId.getBytes("UTF-8"), "HmacSHA256"));
    byte[] hmac = mac.doFinal(jsonPayload.getBytes("UTF-8"));

    // 4. Base64URL(无 padding)
    String computed = Base64.getUrlEncoder().withoutPadding().encodeToString(hmac);
    return computed.equals(payload.get("sign"));
}
python
import hmac
import hashlib
import base64
import json

def verify_webhook_sign(payload: dict, secret_id: str) -> bool:
    # 1. 过滤空字段,排除 sign
    filtered = {k: v for k, v in payload.items()
                if k != 'sign' and v is not None and v != ''}
    sorted_payload = dict(sorted(filtered.items()))

    # 2. 紧凑 JSON
    json_str = json.dumps(sorted_payload, separators=(',', ':'), ensure_ascii=False)

    # 3. HMAC-SHA256
    h = hmac.new(secret_id.encode('utf-8'), json_str.encode('utf-8'), hashlib.sha256)

    # 4. Base64URL(无 padding)
    computed = base64.urlsafe_b64encode(h.digest()).rstrip(b'=').decode()
    return computed == payload.get('sign')

安全建议

  1. 验签:校验 sign 签名是否匹配
  2. 时效:校验 timestamp 是否在允许窗口内(建议 ±5 分钟)
  3. 防重放:校验 nonce 是否已使用(结合 Redis 缓存)

回调示例

json
{
  "type": "PAY",
  "transactionOrderId": "P202602190001",
  "relationId": "MERCHANT-ORDER-001",
  "merchantId": "M10001",
  "status": "100000",
  "payAmount": "100.00",
  "currency": "USD",
  "txnAmount": "100.00",
  "txnCurrency": "USD",
  "responseTime": "2026-02-19 10:12:11",
  "paymentType": "CARD",
  "payment": "pacypay",
  "resultType": 1,
  "timestamp": "1760859131000",
  "nonce": "a8a1f43d6c0b4b2a9a1f2c5d8e7a1234",
  "signType": "HMAC-SHA256",
  "sign": "S9s9oY7k4x6YHfE6Jr3gqE2b3-9oCqHkQb1Tx3dWbPo"
}

回调响应

  • 返回 HTTP 200 表示已成功接收,系统停止重试
  • resultType=2,在响应 body 中返回 URL 字符串(用于特殊场景跳转)

Codrimpay