争议订单WEBHOOK回调
1. 争议订单回调
1.1 三种单据
争议链路会推送三类通知,资金后果完全不同,请务必区分处理:
| 类型 | orderType | 说明 | 你的资金 | 你要做什么 |
|---|---|---|---|---|
| 拒付预警 | rdr / ethoca / efw | 拒付发生之前的预警,通常以自动退款解决 | 不冻结本金 | 通常无需操作,见下方说明 |
| 调单 | inquiry | 发卡行索取资料,不是拒付 | 不冻结、不扣本金 | 退款 / 提交抗辩 / 不处理 |
| 正式拒付 | chargeback | 持卡人已正式发起争议 | 冻结本金 + 手续费 | 接受争议 / 提交抗辩 |
两点容易误解的地方:
- 调单不处理只会过期,不扣本金;但发卡行可能将其升级为正式拒付,届时你会收到一条新的
DISPUTE通知,资金在那一刻才冻结。 - 三种预警里只有 RDR 和 Ethoca 是确定自动退款的。EFW(早期欺诈预警)是否自动退款取决于你的账户配置,所以受理通知里的
fundImpact会是MERCHANT_ACTION(待你决定)而不是AUTO_REFUNDED;如果后续确实自动退款了,你会再收到一条resultCode=SUCCEED的通知。
不要靠 orderType 推断资金后果,以 fundImpact 字段为准(见 1.5)。
1.2 请求
我方以 POST 推送到你配置的回调地址。
请求头
| 头 | 示例 | 说明 |
|---|---|---|
| Content-Type | application/json | 固定值 |
| Authorization | fbf4cb87… | 签名,验签方式见 1.6 |
| merchantId | 1 | 商户号 |
| appId | 2 | 应用号 |
| curTime | 2026-09-11 11:16:42 | 推送时间,格式 yyyy-MM-dd HH:mm:ss |
| X-Error-Message | Fraud | 争议原因(卡组织原因码或预警类型)。为控制头部体积会做截断,超长时结尾为 … |
X-Error-Message 仅供排查参考,不要用它做业务判断,请以请求体中的 networkReasonCode 为准。
请求体示例
{
"appId": "2",
"merchantId": "1",
"notificationItems": [
{
"additionalData": {
"orderType": "chargeback",
"networkReasonCode": "10.4",
"providerReference": "du_1UESQM08pryDCbBWNzLD28lZ",
"fundImpact": "SETTLED_LOST",
"disputeDueBy": "2026-09-19 00:00:00"
},
"amount": { "currency": "USD", "value": 200 },
"eventCode": "DISPUTE",
"eventDate": 1789125636000,
"merchantReference": "35D58738ACA345E3BB380D6003747DD8",
"originalReference": "2098369735585562624",
"pspReference": "CB2098370142512742400",
"resultCode": "FAILED"
}
]
}如何应答
我方只看 HTTP 状态码:返回 200 即视为你已收到,响应体内容不做任何校验。
任何非 200 的状态码、连接超时、读超时、DNS 失败,都算投递失败。
重试策略
投递失败后按固定间隔 10 秒重试,最多 10 次(约 100 秒内结束)。次数用尽后不再推送。
因此请不要把回调当作唯一数据来源:如果你的服务有超过两分钟的不可用,就可能永久错过这条通知。建议定期用订单查询接口按 originalReference 反查关联单做对账兜底。
1.3 事件码
| eventCode | 含义 |
|---|---|
DISPUTE | 正式拒付 |
INQUIRY | 调单 |
WARNING_RDR | RDR 拒付预警 |
WARNING_ETHOCA | Ethoca 拒付预警 |
WARNING_EFW | 早期欺诈预警 |
请忽略无法识别的 eventCode,直接返回 200,不要报错。 后续接入新的预警渠道会带来新的 WARNING_* 事件码,把它们当作未知事件跳过即可。
1.4 字段说明
notificationItems[]
| 字段 | 类型 | 说明 |
|---|---|---|
| eventCode | String | 见 1.3 |
| eventDate | Long | 事件时间,毫秒时间戳 |
| pspReference | String | 本争议单 / 预警单的平台单号(CB… / CW… / EF…) |
| originalReference | String | 原交易的平台单号,用它关联回你的订单 |
| merchantReference | String | 你的商户订单号 |
| resultCode | String | 状态,见 1.5 |
| amount | Object | {currency, value},value 为最小货币单位。仅争议本金,不含手续费 |
| additionalData | Object | 见下表 |
pspReference 与 originalReference 是最容易搞错的一对:pspReference 是争议单自己的单号,originalReference 才是被争议的那笔原交易。请用 originalReference 或 merchantReference 去关联你自己的订单。
additionalData
| key | 出现场景 | 说明 |
|---|---|---|
| orderType | 全部 | chargeback / inquiry / rdr / ethoca / efw,与查询接口取值一致 |
| fundImpact | 全部 | 资金影响,见 1.5 |
| providerReference | 全部 | 上游渠道的争议 ID / 预警 ID |
| disputeDueBy | DISPUTE / INQUIRY | 抗辩截止时间,UTC,格式 yyyy-MM-dd HH:mm:ss |
| networkReasonCode | DISPUTE / INQUIRY | 卡组织原因码 |
| refundPspReference | WARNING_* 且 resultCode=SUCCEED | 我方为该预警发起的退款单号 |
字段无值时不会出现在 additionalData 里,请按「可能不存在」处理,不要假设 key 一定存在。
请务必记录
disputeDueBy并设置提醒:超过该时间提交证据会被卡组织拒绝,争议直接判输。
1.5 状态与资金影响
fundImpact 取值
| 值 | 含义 |
|---|---|
AUTO_REFUNDED | 已自动退款,无需操作 |
MERCHANT_ACTION | 需你决定,当前尚未产生资金影响 |
FROZEN | 本金及手续费已冻结,等待裁决 |
SETTLED_WON | 已赢,冻结金额已释放 |
SETTLED_LOST | 已输,已扣款 |
NONE | 不影响资金 |
状态对照表
| eventCode | resultCode | 含义 | fundImpact | 你要做什么 |
|---|---|---|---|---|
WARNING_RDR | INITIALIZED | RDR 预警受理 | AUTO_REFUNDED | 无需操作 |
WARNING_ETHOCA | INITIALIZED | Ethoca 预警受理 | AUTO_REFUNDED | 无需操作 |
WARNING_EFW | INITIALIZED | 欺诈预警受理 | MERCHANT_ACTION | 可自行决定是否退款 |
WARNING_* | SUCCEED | 已退款,争议避免 | AUTO_REFUNDED | 无需操作 |
WARNING_* | FAILED | 未能退款 | NONE | 留意是否转为正式拒付 |
WARNING_* | CANCEL | 未匹配到交易,已忽略 | NONE | 无需操作 |
INQUIRY | INITIALIZED | 调单,需要回应 | MERCHANT_ACTION | 退款 / 提交抗辩 / 不处理 |
INQUIRY | UNDER_REVIEW_INQUIRY | 审核中 | MERCHANT_ACTION | 等待 |
INQUIRY | EXPIRED | 已过期,未扣本金 | NONE | 无需操作 |
INQUIRY | CANCEL | 已关闭 | NONE | 无需操作 |
DISPUTE | PENDING | 正式拒付,需要回应 | FROZEN | 接受争议 或 提交抗辩 |
DISPUTE | PENDING_REVIEW | 证据已提交待审 | FROZEN | 等待 |
DISPUTE | UNDER_REVIEW | 审核中 | FROZEN | 等待 |
DISPUTE | SUCCEED | 已赢,冻结已释放 | SETTLED_WON | 无需操作 |
DISPUTE | FAILED | 已输,已扣款 | SETTLED_LOST | 无需操作 |
DISPUTE | CANCEL | 已关闭 | NONE | 无需操作 |
上表是典型组合,便于理解流程。但
fundImpact反映的是该笔资金当下的真实冻结状态,不是由resultCode推导出来的。
因此:请直接读fundImpact判断资金后果,不要自己用resultCode去推。
1.6 验签
步骤
- 把请求体解析成通用的键值结构(Map / dict),不要解析成固定字段的类
- 取出
notificationItems的值 - 将其所有层级的对象键按字典序排序,数组顺序保持不变
- 紧凑序列化成 JSON 字符串(无空格、无换行)
- 拼成
notificationItems=<第 4 步的字符串> - 末尾直接追加你的 payinSecurityKey,不加任何分隔符
- 对整串做 SHA-256,转小写十六进制
- 与 Authorization 头比对
可用于离线自测的完整示例
下面这组数据是自洽的,你可以直接拿去验证自己的实现。
收到的请求体:
{"appId":"2","merchantId":"1","notificationItems":[{"additionalData":{"orderType":"inquiry","networkReasonCode":"10","providerReference":"du_1UESLJ08pryDCbBWiCOIcYBm","fundImpact":"MERCHANT_ACTION","disputeDueBy":"2026-09-19 00:00:00"},"amount":{"currency":"USD","value":200},"eventCode":"INQUIRY","eventDate":1789125013000,"merchantReference":"8739F37B4241400B9A449220579D39FC","originalReference":"2098368390426132480","pspReference":"CB2098368489222963200","resultCode":"INITIALIZED"}]}payinSecurityKey(示例):
TEST_111111111112222222222222
按上述步骤拼出的待签名字符串:
notificationItems=[{"additionalData":{"disputeDueBy":"2026-09-19 00:00:00","fundImpact":"MERCHANT_ACTION","networkReasonCode":"10","orderType":"inquiry","providerReference":"du_1UESLJ08pryDCbBWiCOIcYBm"},"amount":{"currency":"USD","value":200},"eventCode":"INQUIRY","eventDate":1789125013000,"merchantReference":"8739F37B4241400B9A449220579D39FC","originalReference":"2098368390426132480","pspReference":"CB2098368489222963200","resultCode":"INITIALIZED"}]TEST_111111111112222222222222
SHA-256 结果(应与 Authorization 头一致):
fbf4cb877dee79d131176036352befdbb98de7b2176a152a96492e7b1c110e3e
请对照原始报文与待签名字符串:
additionalData内部的键已按字典序重排为
disputeDueBy → fundImpact → networkReasonCode → orderType → providerReference,
与报文中的顺序不同。这就是第 3 步的效果,也是最容易漏掉的一步。
Java 示例
public boolean verify(String rawBody, String authorization, String payinSecurityKey) throws Exception {
ObjectMapper mapper = new ObjectMapper();
Map<String, Object> body = mapper.readValue(rawBody, new TypeReference<Map<String, Object>>() {});
String itemsJson = mapper.writeValueAsString(sortDeep(body.get("notificationItems")));
String expected = sha256Hex("notificationItems=" + itemsJson + payinSecurityKey);
return expected.equals(authorization);
}
/** 递归按键排序,数组顺序不动 */
@SuppressWarnings("unchecked")
private Object sortDeep(Object node) {
if (node instanceof Map) {
Map<String, Object> sorted = new TreeMap<>();
((Map<String, Object>) node).forEach((k, v) -> sorted.put(k, sortDeep(v)));
return sorted;
}
if (node instanceof List) {
List<Object> list = new ArrayList<>();
((List<Object>) node).forEach(v -> list.add(sortDeep(v)));
return list;
}
return node;
}
private String sha256Hex(String text) throws Exception {
byte[] hash = MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(hash.length * 2);
for (byte b : hash) {
sb.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16));
}
return sb.toString();
}四个常见错误
| 错误做法 | 后果 |
|---|---|
| 把报文解析成固定字段的类,再重新序列化去算签名 | 你不认识的字段会被丢弃,验签必然失败。必须用通用 Map |
| 把整个请求体拿去算签名 | appId、merchantId 在请求体里但不参与签名,只算 notificationItems |
| 只排了顶层的键,没有递归排序嵌套对象 | additionalData、amount 内部的键顺序不对,验签失败 |
追加密钥时加了 & 或 = | 正确写法是 …]你的key,中间无分隔符 |
1.7 幂等
回调可能重复投递(网络抖动、我方重试、同一争议的多次状态变更),请按
pspReference + eventCode + resultCode 三者组合做幂等。
注意不能只用 pspReference:同一笔争议在生命周期中会以相同的 pspReference
多次推送(受理 → 审核中 → 判定结果),漏掉后两个字段会让你丢掉状态更新。
Updated about 6 hours ago