争议订单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-Typeapplication/json固定值
Authorizationfbf4cb87…签名,验签方式见 1.6
merchantId1商户号
appId2应用号
curTime2026-09-11 11:16:42推送时间,格式 yyyy-MM-dd HH:mm:ss
X-Error-MessageFraud争议原因(卡组织原因码或预警类型)。为控制头部体积会做截断,超长时结尾为

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_RDRRDR 拒付预警
WARNING_ETHOCAEthoca 拒付预警
WARNING_EFW早期欺诈预警

请忽略无法识别的 eventCode,直接返回 200,不要报错。 后续接入新的预警渠道会带来新的 WARNING_* 事件码,把它们当作未知事件跳过即可。


1.4 字段说明

notificationItems[]

字段类型说明
eventCodeString见 1.3
eventDateLong事件时间,毫秒时间戳
pspReferenceString本争议单 / 预警单的平台单号(CB… / CW… / EF…
originalReferenceString原交易的平台单号,用它关联回你的订单
merchantReferenceString你的商户订单号
resultCodeString状态,见 1.5
amountObject{currency, value},value 为最小货币单位。仅争议本金,不含手续费
additionalDataObject见下表

pspReferenceoriginalReference 是最容易搞错的一对:pspReference 是争议单自己的单号,originalReference 才是被争议的那笔原交易。请用 originalReferencemerchantReference 去关联你自己的订单。

additionalData

key出现场景说明
orderType全部chargeback / inquiry / rdr / ethoca / efw,与查询接口取值一致
fundImpact全部资金影响,见 1.5
providerReference全部上游渠道的争议 ID / 预警 ID
disputeDueByDISPUTE / INQUIRY抗辩截止时间,UTC,格式 yyyy-MM-dd HH:mm:ss
networkReasonCodeDISPUTE / INQUIRY卡组织原因码
refundPspReferenceWARNING_* 且 resultCode=SUCCEED我方为该预警发起的退款单号

字段无值时不会出现additionalData 里,请按「可能不存在」处理,不要假设 key 一定存在。

请务必记录 disputeDueBy 并设置提醒:超过该时间提交证据会被卡组织拒绝,争议直接判输。


1.5 状态与资金影响

fundImpact 取值

含义
AUTO_REFUNDED已自动退款,无需操作
MERCHANT_ACTION需你决定,当前尚未产生资金影响
FROZEN本金及手续费已冻结,等待裁决
SETTLED_WON已赢,冻结金额已释放
SETTLED_LOST已输,已扣款
NONE不影响资金

状态对照表

eventCoderesultCode含义fundImpact你要做什么
WARNING_RDRINITIALIZEDRDR 预警受理AUTO_REFUNDED无需操作
WARNING_ETHOCAINITIALIZEDEthoca 预警受理AUTO_REFUNDED无需操作
WARNING_EFWINITIALIZED欺诈预警受理MERCHANT_ACTION可自行决定是否退款
WARNING_*SUCCEED已退款,争议避免AUTO_REFUNDED无需操作
WARNING_*FAILED未能退款NONE留意是否转为正式拒付
WARNING_*CANCEL未匹配到交易,已忽略NONE无需操作
INQUIRYINITIALIZED调单,需要回应MERCHANT_ACTION退款 / 提交抗辩 / 不处理
INQUIRYUNDER_REVIEW_INQUIRY审核中MERCHANT_ACTION等待
INQUIRYEXPIRED已过期,未扣本金NONE无需操作
INQUIRYCANCEL已关闭NONE无需操作
DISPUTEPENDING正式拒付,需要回应FROZEN接受争议 或 提交抗辩
DISPUTEPENDING_REVIEW证据已提交待审FROZEN等待
DISPUTEUNDER_REVIEW审核中FROZEN等待
DISPUTESUCCEED已赢,冻结已释放SETTLED_WON无需操作
DISPUTEFAILED已输,已扣款SETTLED_LOST无需操作
DISPUTECANCEL已关闭NONE无需操作

上表是典型组合,便于理解流程。但 fundImpact 反映的是该笔资金当下的真实冻结状态,不是由 resultCode 推导出来的。
因此:请直接读 fundImpact 判断资金后果,不要自己用 resultCode 去推。


1.6 验签

步骤

  1. 把请求体解析成通用的键值结构(Map / dict),不要解析成固定字段的类
  2. 取出 notificationItems 的值
  3. 将其所有层级的对象键按字典序排序,数组顺序保持不变
  4. 紧凑序列化成 JSON 字符串(无空格、无换行)
  5. 拼成 notificationItems=<第 4 步的字符串>
  6. 末尾直接追加你的 payinSecurityKey,不加任何分隔符
  7. 对整串做 SHA-256,转小写十六进制
  8. 与 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
把整个请求体拿去算签名appIdmerchantId 在请求体里但不参与签名,只算 notificationItems
只排了顶层的键,没有递归排序嵌套对象additionalDataamount 内部的键顺序不对,验签失败
追加密钥时加了 &=正确写法是 …]你的key,中间无分隔符

1.7 幂等

回调可能重复投递(网络抖动、我方重试、同一争议的多次状态变更),请按
pspReference + eventCode + resultCode 三者组合做幂等。

注意不能只用 pspReference:同一笔争议在生命周期中会以相同的 pspReference
多次推送(受理 → 审核中 → 判定结果),漏掉后两个字段会让你丢掉状态更新。


Did this page help you?