Appearance
Webhook 回调规范
概述
支付、退款或取消完成后,Codrimpay 会向商户配置的回调地址发送 HTTP POST 通知。
回调地址优先级:
- 创建交易时传入的
notifyUrl - 商户配置中的
webHookUrl
请求说明
- 方法:
POST - Content-Type:
application/json - 重试机制:商户未返回 HTTP 200 时,系统会自动重试
回调字段
| 字段名 | 类型 | 说明 |
|---|---|---|
| type | String | 通知类型:PAY / REFUND / CANCEL |
| transactionOrderId | String | 支付订单号 |
| refundTransactionId | String | 退款单号(退款场景) |
| relationId | String | 商户关联 ID |
| merchantId | String | 商户号 |
| status | String | 订单状态码,见状态码说明 |
| payAmount | String | 支付金额(PAY)/ 退款金额(REFUND) |
| txnAmount | String | 渠道侧交易金额 |
| txnCurrency | String | 渠道侧币种 |
| currency | String | 订单币种 |
| responseTime | String | 渠道回调时间 |
| paymentType | String | 支付类型(如 CARD、KLARNA) |
| payment | String | 支付渠道(如 pacypay、paypal) |
| failedMsg | String | 失败原因描述 |
| failedCode | String | 失败原因码 |
| resultType | Integer | 返回类型:1 默认,2 需返回 URL |
| timestamp | String | 回调时间戳(毫秒) |
| nonce | String | 随机串(防重放) |
| signType | String | 签名类型,固定 HMAC-SHA256 |
| sign | String | 签名值(Base64URL,无 padding) |
签名验证
IMPORTANT
收到回调后必须验签,防止伪造请求。
签名算法
- 过滤空字段(
sign字段本身不参与) - 按字段名字典序升序排序
- 将排序后的字段序列化为紧凑 JSON 字符串
- 使用
HMAC-SHA256(密钥为商户SecretId)计算签名 - 结果为 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')安全建议
- 验签:校验
sign签名是否匹配 - 时效:校验
timestamp是否在允许窗口内(建议 ±5 分钟) - 防重放:校验
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 字符串(用于特殊场景跳转)
